From 5f04b42bdd2337eeaa70e8122a4eb739cc66501b Mon Sep 17 00:00:00 2001 From: David Montero Date: Thu, 4 Jun 2026 19:14:51 +0200 Subject: [PATCH] fix(canvas): while running, the canvas is interact-only (no wire/pin/edit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: on a running circuit, clicking a pushbutton SELECTED the wire under it instead of pressing the button — and you could still move wires / pick pins to make connections during a run. Root cause: component dragging was already locked during a run, but the canvas-level onClick (wire selection via findWireNearPoint) wasn't — so a click on a button bubbled to the canvas and selected the wire. The button press itself fired (shadow DOM), but the wire-select made it feel broken. Gate every EDIT interaction on the existing interactionRunning predicate while keeping part interaction (buttons/switches/pots) and pan/zoom: - canvas onClick wire-selection + onDoubleClick waypoint-insert - wire segment / waypoint drag handles (mouse + touch) - pin-click wire creation - touch tap wire-selection - hide the PinOverlay (was gated on !running, so board-less runs still showed clickable pins) and skip wire-hover highlighting while running - clear any wire/component selection when a run starts so leftover handles don't linger over the live circuit Component drag + property dialog were already gated on interactionRunning; this extends the same 'freeze to edit, run to interact' model to wires and pins. Co-Authored-By: Claude Opus 4.8 --- .../components/simulator/SimulatorCanvas.tsx | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 253fdb56..ec68a019 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -286,6 +286,16 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { runningRef.current = running; const interactionRunningRef = useRef(interactionRunning); interactionRunningRef.current = interactionRunning; + + // When a run starts the canvas becomes interact-only — drop any edit + // selection so leftover wire/segment handles don't linger over the circuit. + useEffect(() => { + if (interactionRunning) { + setSelectedWire(null); + setSelectedComponentId(null); + } + }, [interactionRunning, setSelectedWire]); + const componentsRef = useRef(components); componentsRef.current = components; const boardPositionRef = useRef(boardPosition); @@ -941,7 +951,8 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { } // ── Short tap on empty canvas: wire selection + double-tap inserts waypoint ── - if (isShortTap) { + // Disabled while running — the canvas is interact-only then. + if (isShortTap && !interactionRunningRef.current) { const now = Date.now(); const world = toWorld(changed.clientX, changed.clientY); const baseThreshold = isTouchDeviceRef.current ? 20 : 8; @@ -1434,8 +1445,9 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { return; } - // Wire hover detection (when not dragging anything) - if (!draggedComponentId) { + // Wire hover detection (when not dragging anything). Skipped while running — + // wires aren't selectable then, so they shouldn't highlight as hoverable. + if (!draggedComponentId && !interactionRunningRef.current) { const world = toWorld(e.clientX, e.clientY); const threshold = 8 / zoomRef.current; const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold); @@ -1614,6 +1626,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { (e: React.MouseEvent, segIndex: number) => { e.stopPropagation(); e.preventDefault(); + if (interactionRunningRef.current) return; // interact-only while running if (!selectedWireId) return; const wire = wiresRef.current.find((w) => w.id === selectedWireId); if (!wire) return; @@ -1636,6 +1649,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { const handleHandleTouchStart = useCallback( (e: React.TouchEvent, segIndex: number) => { e.stopPropagation(); + if (interactionRunningRef.current) return; if (!selectedWireId) return; const wire = wiresRef.current.find((w) => w.id === selectedWireId); if (!wire) return; @@ -1659,6 +1673,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { (e: React.MouseEvent, waypointIndex: number) => { e.stopPropagation(); e.preventDefault(); + if (interactionRunningRef.current) return; // interact-only while running if (!selectedWireId) return; const wire = wiresRef.current.find((w) => w.id === selectedWireId); if (!wire) return; @@ -1676,6 +1691,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { const handleWaypointTouchStart = useCallback( (e: React.TouchEvent, waypointIndex: number) => { e.stopPropagation(); + if (interactionRunningRef.current) return; if (!selectedWireId) return; const wire = wiresRef.current.find((w) => w.id === selectedWireId); if (!wire) return; @@ -1723,6 +1739,8 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { // Wire creation via pin clicks const handlePinClick = (componentId: string, pinName: string, x: number, y: number) => { + // No making connections while the simulation runs — interact-only. + if (interactionRunningRef.current) return; // Close property dialog when starting wire creation if (showPropertyDialog) { setShowPropertyDialog(false); @@ -1928,7 +1946,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { isSelected={isSelected} onMouseDown={(e) => handleComponentMouseDown(component.id, e)} /> - {!running && ( + {!interactionRunning && ( { }} /> - {/* Pin overlay for wire creation - hide when running */} - {!running && ( + {/* Pin overlay for wire creation - hide while interacting/running */} + {!interactionRunning && ( { segmentDragJustCommittedRef.current = false; return; } + // While the simulation runs the canvas is interact-only: a click on + // a button must press it (its own handler), not select the wire + // underneath it for editing. + if (interactionRunning) return; // Wire selection via canvas-level hit detection const world = toWorld(e.clientX, e.clientY); const threshold = 8 / zoomRef.current; @@ -2395,7 +2417,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { } }} onDoubleClick={(e) => { - if (wireInProgress) return; + if (wireInProgress || interactionRunning) return; const world = toWorld(e.clientX, e.clientY); const threshold = 8 / zoomRef.current; const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold); @@ -2533,7 +2555,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { the right-click menu — the floating bar covered nearby pins and intercepted clicks on buttons during simulation) - while the simulator is running (canvas is read-only). */} - {!wireInProgress && isTouchDevice && !running && + {!wireInProgress && isTouchDevice && !interactionRunning && (() => { if (selectedWireId) { const wire = wires.find((w) => w.id === selectedWireId);