diff --git a/docs/wiki/component-interaction.md b/docs/wiki/component-interaction.md new file mode 100644 index 00000000..afb80c34 --- /dev/null +++ b/docs/wiki/component-interaction.md @@ -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 `` 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 ``, 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. diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index a3c8eb23..57d6e023 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -277,36 +277,49 @@ export const DynamicComponent: React.FC = ({ const handleMouseDown = useCallback( (e: React.MouseEvent) => { if (!onMouseDown) return; - // Don't swallow the pointerdown when the user is interacting with a - // live wokwi component (rotating a potentiometer knob, pressing a - // pushbutton, toggling a slide-switch, dragging the joystick stick, - // etc.). The wokwi-elements internally use pointerdown/move/up on - // their shadow-DOM SVGs; if we call stopPropagation() in the capture - // phase here, that internal logic NEVER receives the event and the - // knob can't rotate, the button never reports pressed, etc. + // Don't swallow the pointerdown for wokwi components that own their + // own pointer interaction (rotary knobs, pushbuttons, slide-switches, + // joysticks, keypads, encoders). For those the wokwi element binds + // pointerdown/move/up on its shadow-DOM SVG; if we call + // stopPropagation() in the capture phase here the internal logic + // never sees the event and the knob can't rotate, the button never + // reports pressed, etc. // - // The canvas's drag-to-rearrange flow still works: the user can grab - // the component WRAPPER (its padding/border) or any non-interactive - // part of the body. For interactive parts the wokwi component owns - // the click — same UX as Wokwi/Tinkercad. + // EVERY OTHER component (sensors, displays, LEDs, resistors, even + // ones with attachEvents for the sensor-update / SPICE-prop bridge) + // expects clicks to bubble up to the canvas → open the property + // 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 - // running (interactionRunning=false): the user is in "edit mode" - // arranging the board, so capturing the click into a canvas drag - // is the correct behaviour even on interactive components. + // The whitelist below is tight on purpose: only add a tag name when + // the wokwi element actually has its own pointerdown handler that + // the user needs to reach. If a new interactive part is added, + // append its tag here. const target = e.target as HTMLElement; - const targetIsInteractive = - isInteractive && + const tag = target.tagName?.toLowerCase() ?? ''; + const ownsPointer = interactionRunning && - target.tagName.toLowerCase().startsWith('wokwi-'); - if (targetIsInteractive) { + (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 (ownsPointer) { // Let the wokwi component own this pointerdown. return; } e.stopPropagation(); onMouseDown(e); }, - [onMouseDown, isInteractive, interactionRunning], + [onMouseDown, interactionRunning], ); const handleDoubleClick = useCallback(