diff --git a/frontend/src/components/simulator/BoardOnCanvas.tsx b/frontend/src/components/simulator/BoardOnCanvas.tsx index 74eeb84..e3d33a1 100644 --- a/frontend/src/components/simulator/BoardOnCanvas.tsx +++ b/frontend/src/components/simulator/BoardOnCanvas.tsx @@ -40,6 +40,7 @@ interface BoardOnCanvasProps { isActive?: boolean; onMouseDown: (e: React.MouseEvent) => void; onPinClick: (componentId: string, pinName: string, x: number, y: number) => void; + zoom?: number; } export const BoardOnCanvas = ({ @@ -49,6 +50,7 @@ export const BoardOnCanvas = ({ isActive = false, onMouseDown, onPinClick, + zoom = 1, }: BoardOnCanvasProps) => { const { id, boardKind, x, y } = board; const size = BOARD_SIZE[boardKind] ?? { w: 300, h: 200 }; @@ -156,6 +158,7 @@ export const BoardOnCanvas = ({ showPins={true} wrapperOffsetX={0} wrapperOffsetY={0} + zoom={zoom} /> ); diff --git a/frontend/src/components/simulator/PinOverlay.tsx b/frontend/src/components/simulator/PinOverlay.tsx index e50d315..c45dc28 100644 --- a/frontend/src/components/simulator/PinOverlay.tsx +++ b/frontend/src/components/simulator/PinOverlay.tsx @@ -3,10 +3,22 @@ * * Renders clickable pin indicators over components to enable wire creation. * Shows when hovering over a component or when creating a wire. + * + * On touch devices the hit-target is scaled up inversely to the canvas zoom + * so the *screen-space* tap area stays at least ~40px regardless of zoom level. */ import React, { useEffect, useState } from 'react'; +/** Detect touch-capable device once */ +const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0); + +/** Minimum visual pin size in *world* pixels at zoom 1 */ +const PIN_VISUAL = 12; + +/** Desired minimum screen-space hit-target size for touch (px) */ +const TOUCH_MIN_SCREEN_PX = 44; + interface PinInfo { name: string; x: number; // CSS pixels @@ -23,6 +35,8 @@ interface PinOverlayProps { /** Extra offset to compensate for wrapper padding/border. Default: 4 (x), 6 (y) for component wrappers. Pass 0 when the element has no wrapper. */ wrapperOffsetX?: number; wrapperOffsetY?: number; + /** Current canvas zoom level — used to keep touch targets usable at any zoom */ + zoom?: number; } export const PinOverlay: React.FC = ({ @@ -33,6 +47,7 @@ export const PinOverlay: React.FC = ({ showPins, wrapperOffsetX = 4, wrapperOffsetY = 6, + zoom = 1, }) => { const [pins, setPins] = useState([]); @@ -56,6 +71,13 @@ export const PinOverlay: React.FC = ({ return null; } + // On touch devices, compute world-space size so the pin is at least + // TOUCH_MIN_SCREEN_PX on screen. On desktop, keep the original 12px. + const pinSize = isTouchDevice + ? Math.max(PIN_VISUAL, TOUCH_MIN_SCREEN_PX / zoom) + : PIN_VISUAL; + const pinHalf = pinSize / 2; + return (
= ({ }} > {pins.map((pin, index) => { - // Pin coordinates are already in CSS pixels const pinX = pin.x; const pinY = pin.y; @@ -81,20 +102,22 @@ export const PinOverlay: React.FC = ({ }} onTouchEnd={(e) => { e.stopPropagation(); + e.preventDefault(); onPinClick(componentId, pin.name, componentX + wrapperOffsetX + pinX, componentY + wrapperOffsetY + pinY); }} style={{ position: 'absolute', - left: `${pinX - 6}px`, - top: `${pinY - 6}px`, - width: '12px', - height: '12px', + left: `${pinX - pinHalf}px`, + top: `${pinY - pinHalf}px`, + width: `${pinSize}px`, + height: `${pinSize}px`, borderRadius: '3px', backgroundColor: 'rgba(0, 200, 255, 0.8)', border: '1.5px solid white', cursor: 'crosshair', pointerEvents: 'all', transition: 'all 0.15s', + touchAction: 'none', }} onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'rgba(0, 255, 100, 1)'; diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 61dadb1..bf2a77f 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -23,6 +23,9 @@ import { renderedToWaypoints, renderedPointsToPath, } from '../../utils/wireHitDetection'; + +/** Detect touch-capable device once */ +const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0); import type { ComponentMetadata } from '../../types/component-metadata'; import type { BoardKind } from '../../types/board'; import { BOARD_KIND_LABELS } from '../../types/board'; @@ -396,6 +399,18 @@ export const SimulatorCanvas = () => { if (e.touches.length !== 1) return; const touch = e.touches[0]; + // ── Segment drag (wire editing) via touch ── + if (segmentDragRef.current) { + const world = toWorld(touch.clientX, touch.clientY); + const sd = segmentDragRef.current; + sd.isDragging = true; + const newValue = sd.axis === 'horizontal' ? world.y : world.x; + const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue); + const overridePath = renderedPointsToPath(newPts); + setSegmentDragPreview({ wireId: sd.wireId, overridePath }); + return; + } + // ── Wire preview: update position as finger moves ── if (wireInProgressRef.current && !isPanningRef.current && !touchDraggedComponentIdRef.current) { const world = toWorld(touch.clientX, touch.clientY); @@ -464,6 +479,24 @@ export const SimulatorCanvas = () => { if (e.touches.length > 0) return; // Still fingers on screen + // ── Finish segment drag (wire editing) via touch ── + if (segmentDragRef.current) { + const sd = segmentDragRef.current; + if (sd.isDragging) { + segmentDragJustCommittedRef.current = true; + const changed = e.changedTouches[0]; + if (changed) { + const world = toWorld(changed.clientX, changed.clientY); + const newValue = sd.axis === 'horizontal' ? world.y : world.x; + const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue); + updateWire(sd.wireId, { waypoints: renderedToWaypoints(newPts) }); + } + } + segmentDragRef.current = null; + setSegmentDragPreview(null); + return; + } + // ── Finish panning ── let wasPanning = false; if (isPanningRef.current) { @@ -480,7 +513,7 @@ export const SimulatorCanvas = () => { const dx = changed.clientX - touchClickStartPosRef.current.x; const dy = changed.clientY - touchClickStartPosRef.current.y; const dist = Math.sqrt(dx * dx + dy * dy); - const isShortTap = dist < 10 && elapsed < 400; + const isShortTap = dist < 20 && elapsed < 400; // If we actually panned (moved significantly), don't process as tap if (wasPanning && !isShortTap) return; @@ -530,7 +563,8 @@ export const SimulatorCanvas = () => { if (isShortTap) { const now = Date.now(); const world = toWorld(changed.clientX, changed.clientY); - const threshold = 8 / zoomRef.current; + const baseThreshold = isTouchDevice ? 20 : 8; + const threshold = baseThreshold / zoomRef.current; const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold); // Double-tap → delete wire @@ -961,6 +995,28 @@ export const SimulatorCanvas = () => { [selectedWireId], ); + // Handle touchstart on a segment handle circle (mobile wire editing) + const handleHandleTouchStart = useCallback( + (e: React.TouchEvent, segIndex: number) => { + e.stopPropagation(); + if (!selectedWireId) return; + const wire = wiresRef.current.find((w) => w.id === selectedWireId); + if (!wire) return; + const segments = getRenderedSegments(wire); + const seg = segments[segIndex]; + if (!seg) return; + const expandedPts = getRenderedPoints(wire); + segmentDragRef.current = { + wireId: wire.id, + segIndex, + axis: seg.axis, + renderedPts: expandedPts, + isDragging: false, + }; + }, + [selectedWireId], + ); + // Zoom centered on cursor const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); @@ -1135,6 +1191,7 @@ export const SimulatorCanvas = () => { componentY={component.y} onPinClick={handlePinClick} showPins={showPinsForComponent} + zoom={zoom} /> )} @@ -1325,6 +1382,7 @@ export const SimulatorCanvas = () => { segmentDragPreview={segmentDragPreview} segmentHandles={segmentHandles} onHandleMouseDown={handleHandleMouseDown} + onHandleTouchStart={handleHandleTouchStart} /> {/* All boards on canvas */} @@ -1343,6 +1401,7 @@ export const SimulatorCanvas = () => { setDragOffset({ x: world.x - board.x, y: world.y - board.y }); }} onPinClick={handlePinClick} + zoom={zoom} /> ))} diff --git a/frontend/src/components/simulator/WireLayer.tsx b/frontend/src/components/simulator/WireLayer.tsx index c2c59dc..bf7d264 100644 --- a/frontend/src/components/simulator/WireLayer.tsx +++ b/frontend/src/components/simulator/WireLayer.tsx @@ -3,6 +3,8 @@ import { useSimulatorStore } from '../../store/useSimulatorStore'; import { WireRenderer } from './WireRenderer'; import { WireInProgressRenderer } from './WireInProgressRenderer'; +const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0); + export interface SegmentHandle { segIndex: number; axis: 'horizontal' | 'vertical'; @@ -18,6 +20,8 @@ interface WireLayerProps { segmentHandles: SegmentHandle[]; /** Called when user starts dragging a handle (passes segIndex) */ onHandleMouseDown: (e: React.MouseEvent, segIndex: number) => void; + /** Called when user starts dragging a handle via touch (passes segIndex) */ + onHandleTouchStart?: (e: React.TouchEvent, segIndex: number) => void; } export const WireLayer: React.FC = ({ @@ -25,6 +29,7 @@ export const WireLayer: React.FC = ({ segmentDragPreview, segmentHandles, onHandleMouseDown, + onHandleTouchStart, }) => { const wires = useSimulatorStore((s) => s.wires); const wireInProgress = useSimulatorStore((s) => s.wireInProgress); @@ -64,12 +69,13 @@ export const WireLayer: React.FC = ({ key={handle.segIndex} cx={handle.mx} cy={handle.my} - r={7} + r={isTouchDevice ? 14 : 7} fill="white" stroke="#007acc" strokeWidth={2} - style={{ pointerEvents: 'all', cursor: handle.axis === 'horizontal' ? 'ns-resize' : 'ew-resize' }} + style={{ pointerEvents: 'all', cursor: handle.axis === 'horizontal' ? 'ns-resize' : 'ew-resize', touchAction: 'none' }} onMouseDown={(e) => onHandleMouseDown(e, handle.segIndex)} + onTouchStart={(e) => onHandleTouchStart?.(e, handle.segIndex)} /> ))} diff --git a/frontend/src/utils/analytics.ts b/frontend/src/utils/analytics.ts index 9fa3418..7e7c940 100644 --- a/frontend/src/utils/analytics.ts +++ b/frontend/src/utils/analytics.ts @@ -5,9 +5,10 @@ * Each function maps to an event that should be marked as a Key Event in GA4. */ -declare function gtag(command: 'event', eventName: string, eventParams?: Record): void; +type GtagFn = (command: 'event', eventName: string, eventParams?: Record) => void; function fireEvent(eventName: string, params: Record): void { + const gtag = (window as unknown as { gtag?: GtagFn }).gtag; if (typeof gtag === 'function') { gtag('event', eventName, params); }