From ad67e88320b64335908e5c390ea334803d5abb5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 02:38:15 +0000 Subject: [PATCH 2/2] feat: add mobile touch support to simulator canvas - Single-finger pan on empty canvas background - Single-finger drag for components and board - Two-finger pinch-to-zoom centered on pinch midpoint - Tap to open property dialog / deselect on mobile - Add touch-action: none CSS to prevent browser scroll conflicts - Add data-board-overlay attribute for touch target detection Co-authored-by: davidmonterocrespo24 <47928504+davidmonterocrespo24@users.noreply.github.com> --- .../components/simulator/SimulatorCanvas.css | 1 + .../components/simulator/SimulatorCanvas.tsx | 226 ++++++++++++++++++ 2 files changed, 227 insertions(+) diff --git a/frontend/src/components/simulator/SimulatorCanvas.css b/frontend/src/components/simulator/SimulatorCanvas.css index 747ea24..d7c6783 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.css +++ b/frontend/src/components/simulator/SimulatorCanvas.css @@ -160,6 +160,7 @@ repeating-linear-gradient(0deg, transparent, transparent 19px, rgba(255,255,255,0.025) 19px, rgba(255,255,255,0.025) 20px), repeating-linear-gradient(90deg, transparent, transparent 19px, rgba(255,255,255,0.025) 19px, rgba(255,255,255,0.025) 20px); user-select: none; + touch-action: none; /* Disable browser scroll/zoom so JS handlers take full control */ } /* ── Infinite canvas world ───────────────────────── */ diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 693f61e..6f44861 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -85,6 +85,25 @@ export const SimulatorCanvas = () => { const panRef = useRef({ x: 0, y: 0 }); const zoomRef = useRef(1); + // Refs that mirror state/props for use inside touch event closures + // (touch listeners are added imperatively and can't access current React state) + const runningRef = useRef(running); + runningRef.current = running; + const componentsRef = useRef(components); + componentsRef.current = components; + const boardPositionRef = useRef(boardPosition); + boardPositionRef.current = boardPosition; + + // Touch-specific state refs (for single-finger drag and pinch-to-zoom) + const touchDraggedComponentIdRef = useRef(null); + const touchDragOffsetRef = useRef({ x: 0, y: 0 }); + const touchClickStartTimeRef = useRef(0); + const touchClickStartPosRef = useRef({ x: 0, y: 0 }); + const pinchStartDistRef = useRef(0); + const pinchStartZoomRef = useRef(1); + const pinchStartMidRef = useRef({ x: 0, y: 0 }); + const pinchStartPanRef = useRef({ x: 0, y: 0 }); + // Convert viewport coords to world (canvas) coords const toWorld = useCallback((screenX: number, screenY: number) => { const rect = canvasRef.current?.getBoundingClientRect(); @@ -123,6 +142,212 @@ export const SimulatorCanvas = () => { return () => el.removeEventListener('wheel', onWheel); }, []); + // Attach touch listeners as non-passive so preventDefault() works, enabling + // single-finger pan, single-finger component drag, and two-finger pinch-to-zoom. + useEffect(() => { + const el = canvasRef.current; + if (!el) return; + + const onTouchStart = (e: TouchEvent) => { + e.preventDefault(); // Prevent browser scroll / mouse-event synthesis + + pinchStartDistRef.current = 0; // Reset pinch state on each new gesture + + if (e.touches.length === 2) { + // ── Two-finger pinch: cancel any active drag/pan and prepare zoom ── + isPanningRef.current = false; + touchDraggedComponentIdRef.current = null; + + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + pinchStartDistRef.current = Math.sqrt(dx * dx + dy * dy); + pinchStartZoomRef.current = zoomRef.current; + pinchStartPanRef.current = { ...panRef.current }; + + const rect = el.getBoundingClientRect(); + pinchStartMidRef.current = { + x: (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left, + y: (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top, + }; + return; + } + + if (e.touches.length !== 1) return; + const touch = e.touches[0]; + touchClickStartTimeRef.current = Date.now(); + touchClickStartPosRef.current = { x: touch.clientX, y: touch.clientY }; + + // 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; + + if (componentWrapper && !runningRef.current) { + // ── Single finger on a component: start drag ── + const componentId = componentWrapper.getAttribute('data-component-id'); + if (componentId) { + const component = componentsRef.current.find((c) => c.id === componentId); + if (component) { + const world = toWorld(touch.clientX, touch.clientY); + touchDraggedComponentIdRef.current = componentId; + touchDragOffsetRef.current = { + x: world.x - component.x, + y: world.y - component.y, + }; + setSelectedComponentId(componentId); + } + } + } else if (boardOverlay && !runningRef.current) { + // ── Single finger on the board overlay: start board drag ── + const board = boardPositionRef.current; + const world = toWorld(touch.clientX, touch.clientY); + touchDraggedComponentIdRef.current = '__board__'; + touchDragOffsetRef.current = { + x: world.x - board.x, + y: world.y - board.y, + }; + } else { + // ── Single finger on empty canvas: start pan ── + isPanningRef.current = true; + panStartRef.current = { + mouseX: touch.clientX, + mouseY: touch.clientY, + panX: panRef.current.x, + panY: panRef.current.y, + }; + } + }; + + const onTouchMove = (e: TouchEvent) => { + e.preventDefault(); + + if (e.touches.length === 2 && pinchStartDistRef.current > 0) { + // ── Two-finger pinch: update zoom ── + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + const dist = Math.sqrt(dx * dx + dy * dy); + const scale = dist / pinchStartDistRef.current; + const newZoom = Math.min(5, Math.max(0.1, pinchStartZoomRef.current * scale)); + + const mid = pinchStartMidRef.current; + const startPan = pinchStartPanRef.current; + const startZoom = pinchStartZoomRef.current; + const worldX = (mid.x - startPan.x) / startZoom; + const worldY = (mid.y - startPan.y) / startZoom; + const newPan = { + x: mid.x - worldX * newZoom, + y: mid.y - worldY * newZoom, + }; + + zoomRef.current = newZoom; + panRef.current = newPan; + const worldEl = el.querySelector('.canvas-world') as HTMLElement | null; + if (worldEl) { + worldEl.style.transform = `translate(${newPan.x}px, ${newPan.y}px) scale(${newZoom})`; + } + return; + } + + if (e.touches.length !== 1) return; + const touch = e.touches[0]; + + if (isPanningRef.current) { + // ── Single finger pan ── + const dx = touch.clientX - panStartRef.current.mouseX; + const dy = touch.clientY - panStartRef.current.mouseY; + const newPan = { + x: panStartRef.current.panX + dx, + y: panStartRef.current.panY + dy, + }; + panRef.current = newPan; + const worldEl = el.querySelector('.canvas-world') as HTMLElement | null; + if (worldEl) { + worldEl.style.transform = `translate(${newPan.x}px, ${newPan.y}px) scale(${zoomRef.current})`; + } + } else if (touchDraggedComponentIdRef.current) { + // ── Single finger component/board drag ── + const world = toWorld(touch.clientX, touch.clientY); + if (touchDraggedComponentIdRef.current === '__board__') { + setBoardPosition({ + x: world.x - touchDragOffsetRef.current.x, + y: world.y - touchDragOffsetRef.current.y, + }); + } else { + updateComponent(touchDraggedComponentIdRef.current, { + x: world.x - touchDragOffsetRef.current.x, + y: world.y - touchDragOffsetRef.current.y, + } as any); + } + } + }; + + const onTouchEnd = (e: TouchEvent) => { + e.preventDefault(); + + // ── Finish pinch zoom: commit values to React state ── + if (pinchStartDistRef.current > 0 && e.touches.length < 2) { + setZoom(zoomRef.current); + setPan({ ...panRef.current }); + pinchStartDistRef.current = 0; + } + + if (e.touches.length > 0) return; // Still fingers on screen + + // ── Finish panning ── + if (isPanningRef.current) { + isPanningRef.current = false; + setPan({ ...panRef.current }); + } + + const changed = e.changedTouches[0]; + + // ── Finish component/board drag ── + if (touchDraggedComponentIdRef.current) { + const elapsed = Date.now() - touchClickStartTimeRef.current; + const dx = changed ? changed.clientX - touchClickStartPosRef.current.x : 0; + const dy = changed ? changed.clientY - touchClickStartPosRef.current.y : 0; + const dist = Math.sqrt(dx * dx + dy * dy); + + // Short tap with minimal movement → open property dialog + if (dist < 5 && elapsed < 300 && touchDraggedComponentIdRef.current !== '__board__') { + const component = componentsRef.current.find( + (c) => c.id === touchDraggedComponentIdRef.current + ); + if (component) { + setPropertyDialogComponentId(touchDraggedComponentIdRef.current); + setPropertyDialogPosition({ x: component.x, y: component.y }); + setShowPropertyDialog(true); + } + } + + recalculateAllWirePositions(); + touchDraggedComponentIdRef.current = null; + return; + } + + // ── Short tap on empty canvas: deselect ── + 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); + } + } + }; + + el.addEventListener('touchstart', onTouchStart, { passive: false }); + el.addEventListener('touchmove', onTouchMove, { passive: false }); + el.addEventListener('touchend', onTouchEnd, { passive: false }); + + return () => { + el.removeEventListener('touchstart', onTouchStart); + el.removeEventListener('touchmove', onTouchMove); + el.removeEventListener('touchend', onTouchEnd); + }; + }, [toWorld, setBoardPosition, updateComponent, recalculateAllWirePositions]); + // Recalculate wire positions after web components initialize their pinInfo useEffect(() => { const timer = setTimeout(() => { @@ -643,6 +868,7 @@ export const SimulatorCanvas = () => { {/* Board interaction overlay for dragging */} {!running && (