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:
parent
dd22bcfe50
commit
a9285c6698
|
|
@ -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.
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue