From b821fe9747de2da1045a01c429ce9d6384532c29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 03:05:47 +0000 Subject: [PATCH] feat: add mobile touch support for wire creation and dragging Co-authored-by: davidmonterocrespo24 <47928504+davidmonterocrespo24@users.noreply.github.com> --- frontend/package-lock.json | 46 +------- .../src/components/simulator/PinOverlay.tsx | 13 ++- .../components/simulator/SimulatorCanvas.css | 22 ++++ .../components/simulator/SimulatorCanvas.tsx | 54 ++++++++- .../src/components/simulator/WireRenderer.tsx | 105 +++++++++++++++++- 5 files changed, 189 insertions(+), 51 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 949b301..a96fd10 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1822,7 +1822,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1838,14 +1838,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", @@ -2723,7 +2715,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -2781,16 +2773,6 @@ "node": ">=0.4.0" } }, - "node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", - "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3870,19 +3852,6 @@ "node": ">=10" } }, - "node_modules/marked": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", - "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", - "license": "MIT", - "peer": true, - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3933,17 +3902,6 @@ "node": "*" } }, - "node_modules/monaco-editor": { - "version": "0.55.1", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", - "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", - "license": "MIT", - "peer": true, - "dependencies": { - "dompurify": "3.2.7", - "marked": "14.0.0" - } - }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", diff --git a/frontend/src/components/simulator/PinOverlay.tsx b/frontend/src/components/simulator/PinOverlay.tsx index f18beed..ea8bd7f 100644 --- a/frontend/src/components/simulator/PinOverlay.tsx +++ b/frontend/src/components/simulator/PinOverlay.tsx @@ -63,13 +63,18 @@ export const PinOverlay: React.FC = ({ const pinX = pin.x; const pinY = pin.y; + const handleActivate = (e: React.MouseEvent | React.TouchEvent) => { + e.stopPropagation(); + onPinClick(componentId, pin.name, componentX + wrapperOffsetX + pinX, componentY + wrapperOffsetY + pinY); + }; + return (
{ - e.stopPropagation(); - onPinClick(componentId, pin.name, componentX + wrapperOffsetX + pinX, componentY + wrapperOffsetY + pinY); - }} + data-pin-component-id={componentId} + data-pin-name={pin.name} + onClick={handleActivate} + onTouchEnd={handleActivate} style={{ position: 'absolute', left: `${pinX - 4}px`, diff --git a/frontend/src/components/simulator/SimulatorCanvas.css b/frontend/src/components/simulator/SimulatorCanvas.css index d7c6783..7690c1b 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.css +++ b/frontend/src/components/simulator/SimulatorCanvas.css @@ -150,6 +150,28 @@ cursor: not-allowed; } +/* ── Cancel wire button ──────────────────────────── */ +.cancel-wire-btn { + display: flex; + align-items: center; + gap: 5px; + padding: 0 10px; + height: 32px; + background: #c0392b; + color: #fff; + border: none; + border-radius: 5px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +} + +.cancel-wire-btn:hover { + background: #e74c3c; +} + /* ── Canvas content (viewport) ───────────────────── */ .canvas-content { flex: 1; diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 6f44861..b42de74 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -94,6 +94,18 @@ export const SimulatorCanvas = () => { const boardPositionRef = useRef(boardPosition); boardPositionRef.current = boardPosition; + // Refs for wire actions — needed inside native touch closures + const wireInProgressRef = useRef(wireInProgress); + wireInProgressRef.current = wireInProgress; + const startWireCreationRef = useRef(startWireCreation); + startWireCreationRef.current = startWireCreation; + const finishWireCreationRef = useRef(finishWireCreation); + finishWireCreationRef.current = finishWireCreation; + const cancelWireCreationRef = useRef(cancelWireCreation); + cancelWireCreationRef.current = cancelWireCreation; + const updateWireInProgressRef = useRef(updateWireInProgress); + updateWireInProgressRef.current = updateWireInProgress; + // Touch-specific state refs (for single-finger drag and pinch-to-zoom) const touchDraggedComponentIdRef = useRef(null); const touchDragOffsetRef = useRef({ x: 0, y: 0 }); @@ -103,6 +115,8 @@ export const SimulatorCanvas = () => { const pinchStartZoomRef = useRef(1); const pinchStartMidRef = useRef({ x: 0, y: 0 }); const pinchStartPanRef = useRef({ x: 0, y: 0 }); + // Flag: the most recent single-finger touch started on a pin element + const touchStartedOnPinRef = useRef(false); // Convert viewport coords to world (canvas) coords const toWorld = useCallback((screenX: number, screenY: number) => { @@ -176,12 +190,26 @@ export const SimulatorCanvas = () => { const touch = e.touches[0]; touchClickStartTimeRef.current = Date.now(); touchClickStartPosRef.current = { x: touch.clientX, y: touch.clientY }; + touchStartedOnPinRef.current = false; // Identify what element was touched const target = document.elementFromPoint(touch.clientX, touch.clientY); const componentWrapper = target?.closest('[data-component-id]') as HTMLElement | null; const boardOverlay = target?.closest('[data-board-overlay]') as HTMLElement | null; + // ── Single finger on a pin: handle wire creation (do not pan) ── + const pinEl = target?.closest('[data-pin-component-id]') as HTMLElement | null; + if (pinEl && !runningRef.current) { + touchStartedOnPinRef.current = true; + return; // PinOverlay's onTouchEnd will call onPinClick + } + + // ── Single finger on a wire element: do not pan (WireRenderer handles it) ── + const noPanEl = target?.closest('[data-no-pan]'); + if (noPanEl) { + return; + } + if (componentWrapper && !runningRef.current) { // ── Single finger on a component: start drag ── const componentId = componentWrapper.getAttribute('data-component-id'); @@ -278,6 +306,10 @@ export const SimulatorCanvas = () => { y: world.y - touchDragOffsetRef.current.y, } as any); } + } else if (wireInProgressRef.current) { + // ── Single finger wire-in-progress preview ── + const world = toWorld(touch.clientX, touch.clientY); + updateWireInProgressRef.current(world.x, world.y); } }; @@ -325,16 +357,23 @@ export const SimulatorCanvas = () => { return; } - // ── Short tap on empty canvas: deselect ── + // ── Short tap on empty canvas ── if (changed) { const elapsed = Date.now() - touchClickStartTimeRef.current; const dx = changed.clientX - touchClickStartPosRef.current.x; const dy = changed.clientY - touchClickStartPosRef.current.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 5 && elapsed < 300) { - setSelectedComponentId(null); + // If wire creation is in progress and the tap was NOT on a pin, + // cancel the wire (acts like pressing Escape on desktop). + if (wireInProgressRef.current && !touchStartedOnPinRef.current) { + cancelWireCreationRef.current(); + } else { + setSelectedComponentId(null); + } } } + touchStartedOnPinRef.current = false; }; el.addEventListener('touchstart', onTouchStart, { passive: false }); @@ -782,6 +821,17 @@ export const SimulatorCanvas = () => {
+ {/* Cancel wire creation — shown when a wire is being drawn (especially useful on mobile) */} + {wireInProgress && ( + + )} {/* Zoom controls */}