feat(canvas): drag-threshold lets users move parts while running
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.
This commit is contained in:
parent
3e24811a0d
commit
d193954c2f
|
|
@ -352,6 +352,14 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
|
|||
};
|
||||
}, [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 (
|
||||
<div
|
||||
className="dynamic-component-wrapper"
|
||||
|
|
@ -369,7 +377,7 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
|
|||
transform: properties.rotation ? `rotate(${properties.rotation}deg)` : undefined,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseDownCapture={handleMouseDown}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue