From d193954c2f09b6c16f3746aeeb7841bbf4d71183 Mon Sep 17 00:00:00 2001 From: davidmonterocrespo24 Date: Wed, 13 May 2026 16:51:52 +0200 Subject: [PATCH] feat(canvas): drag-threshold lets users move parts while running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the long-standing "components are frozen during simulation" complaint. Once the user clicked Run, interactive wokwi parts (pushbuttons, slide-switches, potentiometers …) called stopPropagation in their bubble-phase mousedown handlers and the canvas's React onMouseDown never fired — so dragging them to rearrange the layout was impossible without first stopping the sim. Two surgical changes: 1. DynamicComponent.tsx switches the wrapper from `onMouseDown` to `onMouseDownCapture`. Capture phase runs before the inner wokwi-element, so the canvas sees the mousedown regardless of stopPropagation downstream. The existing posDiff < 5 check in mouseup keeps disambiguating click vs drag: a click still falls through to the wokwi-element's own mousedown/up for button-press semantics, only sustained movement promotes to a drag. 2. SimulatorCanvas.tsx's touch path used to early-return on touchstart when interactionRunning + .web-component-container, killing any chance of a touch-drag. Now we remember the touch's start position in pendingTouchDragRef and let the browser keep synthesizing mouse events for the wokwi-element. If the finger drifts past DRAG_PROMOTE_THRESHOLD_PX (8 px) onTouchMove cancels the passthrough and starts a real component drag — dispatching a synthesized mouseup on the original target so the wokwi-element doesn't stay visually pressed mid-drag. --- frontend/src/components/DynamicComponent.tsx | 10 ++- .../components/simulator/SimulatorCanvas.tsx | 82 ++++++++++++++++++- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index a20fb6e8..3566c857 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -352,6 +352,14 @@ export const DynamicComponent: React.FC = ({ }; }, [id, handleComponentEvent, metadata.id, simulator, hexEpoch, wireFingerprint]); + // The wrapper uses `onMouseDownCapture` (not `onMouseDown`) so it sees + // the mousedown BEFORE the inner wokwi-element. Interactive wokwi parts + // (pushbutton, slide-switch, potentiometer …) call stopPropagation in + // their own bubble-phase handlers, which used to prevent any drag from + // starting once the simulator was running. Capture phase fires first + // and lets the canvas's drag-threshold logic distinguish click vs drag + // at mouseup time — so the user can rearrange interactive components + // while simulation is live. return (
= ({ transform: properties.rotation ? `rotate(${properties.rotation}deg)` : undefined, transformOrigin: 'center center', }} - onMouseDown={handleMouseDown} + onMouseDownCapture={handleMouseDown} onDoubleClick={handleDoubleClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 700f596b..4bd9eaa9 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -63,6 +63,13 @@ const LONG_PRESS_MS = 500; /** Max movement during long-press before it cancels (px). */ const LONG_PRESS_MOVE_TOLERANCE = 8; +/** + * Distance (px, screen) a touch must drift before a passthrough-to- + * wokwi-element touch is promoted to a component drag. Set just above + * normal tap jitter so accidental drags from a finger press are rare. + */ +const DRAG_PROMOTE_THRESHOLD_PX = 8; + /** Check if a board kind is an ESP32-family board. */ function isEsp32Kind(kind: BoardKind): boolean { return ( @@ -334,6 +341,15 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { const selectedWireIdRef = useRef(selectedWireId); selectedWireIdRef.current = selectedWireId; const touchPassthroughRef = useRef(false); + // While `touchPassthroughRef` is true (touch let through to an interactive + // wokwi-element while the simulation runs), we still remember which + // component the touch started on. If the finger drifts past + // DRAG_PROMOTE_THRESHOLD_PX, we cancel the passthrough and start a real + // drag — so the user can rearrange interactive parts live without first + // pausing the simulation. + const pendingTouchDragRef = useRef< + { componentId: string; startX: number; startY: number } | null + >(null); const touchOnPinRef = useRef(false); const lastTapTimeRef = useRef(0); @@ -434,6 +450,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { // Reset per-gesture flags touchOnPinRef.current = false; touchPassthroughRef.current = false; + pendingTouchDragRef.current = null; pinchStartDistRef.current = 0; if (e.touches.length === 2) { @@ -487,10 +504,19 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { // touch-action:none on .canvas-content already prevents browser scroll/zoom. // 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. + // We still remember the starting position + component id so that if + // the finger drifts past DRAG_PROMOTE_THRESHOLD_PX onTouchMove can + // cancel the passthrough and start a real drag, letting the user + // rearrange interactive parts live without pausing the simulation. if (interactionRunningRef.current) { const webComp = target?.closest('.web-component-container'); if (webComp) { touchPassthroughRef.current = true; + const componentWrapper = target?.closest('[data-component-id]') as HTMLElement | null; + const cid = componentWrapper?.getAttribute('data-component-id') || null; + pendingTouchDragRef.current = cid + ? { componentId: cid, startX: touch.clientX, startY: touch.clientY } + : null; // Don't preventDefault → browser synthesizes mouse events for the component return; } @@ -579,7 +605,60 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { const onTouchMove = (e: TouchEvent) => { // Let interactive components handle their own touch (potentiometer drag, etc.) - if (touchPassthroughRef.current) return; + // — UNLESS the finger has drifted past DRAG_PROMOTE_THRESHOLD_PX, + // in which case we cancel the passthrough and start a real component + // drag. Lets users rearrange parts during simulation without pausing. + if (touchPassthroughRef.current) { + const pending = pendingTouchDragRef.current; + if (pending && e.touches.length === 1) { + const t = e.touches[0]; + const dx = t.clientX - pending.startX; + const dy = t.clientY - pending.startY; + if (dx * dx + dy * dy > DRAG_PROMOTE_THRESHOLD_PX * DRAG_PROMOTE_THRESHOLD_PX) { + const component = componentsRef.current.find((c) => c.id === pending.componentId); + if (component) { + const world = toWorld(t.clientX, t.clientY); + touchDraggedComponentIdRef.current = pending.componentId; + touchDragOffsetRef.current = { + x: world.x - component.x, + y: world.y - component.y, + }; + // Snapshot the drag start position so touchend can fold the + // move into a single undoable Move command, mirroring the + // mouse-drag path. + dragStartPosRef.current = { x: component.x, y: component.y }; + touchClickStartTimeRef.current = Date.now(); + touchClickStartPosRef.current = { x: t.clientX, y: t.clientY }; + // Release any pending press the wokwi-element registered when + // the browser synthesized the initial mousedown at touchstart. + // Without this the interactive part (button, switch knob) stays + // visually held until the user taps it again. + const target = document.elementFromPoint(pending.startX, pending.startY); + if (target) { + target.dispatchEvent( + new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + clientX: pending.startX, + clientY: pending.startY, + }), + ); + target.dispatchEvent( + new MouseEvent('mouseleave', { bubbles: false }), + ); + } + } + touchPassthroughRef.current = false; + pendingTouchDragRef.current = null; + e.preventDefault(); + // Fall through to normal touch-drag handling below. + } else { + return; + } + } else { + return; + } + } // Pin touch: no move processing needed if (touchOnPinRef.current) { e.preventDefault(); @@ -705,6 +784,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { // Let interactive components handle their own touch if (touchPassthroughRef.current) { touchPassthroughRef.current = false; + pendingTouchDragRef.current = null; return; } // Pin touch: let pin's onTouchEnd React handler deal with it