feat: add mobile touch support for wire creation and dragging

Co-authored-by: davidmonterocrespo24 <47928504+davidmonterocrespo24@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-11 03:05:47 +00:00
parent 357c43ae0d
commit b821fe9747
5 changed files with 189 additions and 51 deletions

View File

@ -1822,7 +1822,7 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -1838,14 +1838,6 @@
"@types/react": "^19.2.0" "@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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.1", "version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
@ -2723,7 +2715,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/data-urls": { "node_modules/data-urls": {
@ -2781,16 +2773,6 @@
"node": ">=0.4.0" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -3870,19 +3852,6 @@
"node": ">=10" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -3933,17 +3902,6 @@
"node": "*" "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": { "node_modules/mrmime": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",

View File

@ -63,13 +63,18 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
const pinX = pin.x; const pinX = pin.x;
const pinY = pin.y; const pinY = pin.y;
const handleActivate = (e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation();
onPinClick(componentId, pin.name, componentX + wrapperOffsetX + pinX, componentY + wrapperOffsetY + pinY);
};
return ( return (
<div <div
key={pin.name} key={pin.name}
onClick={(e) => { data-pin-component-id={componentId}
e.stopPropagation(); data-pin-name={pin.name}
onPinClick(componentId, pin.name, componentX + wrapperOffsetX + pinX, componentY + wrapperOffsetY + pinY); onClick={handleActivate}
}} onTouchEnd={handleActivate}
style={{ style={{
position: 'absolute', position: 'absolute',
left: `${pinX - 4}px`, left: `${pinX - 4}px`,

View File

@ -150,6 +150,28 @@
cursor: not-allowed; 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 (viewport) ───────────────────── */
.canvas-content { .canvas-content {
flex: 1; flex: 1;

View File

@ -94,6 +94,18 @@ export const SimulatorCanvas = () => {
const boardPositionRef = useRef(boardPosition); const boardPositionRef = useRef(boardPosition);
boardPositionRef.current = 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) // Touch-specific state refs (for single-finger drag and pinch-to-zoom)
const touchDraggedComponentIdRef = useRef<string | null>(null); const touchDraggedComponentIdRef = useRef<string | null>(null);
const touchDragOffsetRef = useRef({ x: 0, y: 0 }); const touchDragOffsetRef = useRef({ x: 0, y: 0 });
@ -103,6 +115,8 @@ export const SimulatorCanvas = () => {
const pinchStartZoomRef = useRef(1); const pinchStartZoomRef = useRef(1);
const pinchStartMidRef = useRef({ x: 0, y: 0 }); const pinchStartMidRef = useRef({ x: 0, y: 0 });
const pinchStartPanRef = 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 // Convert viewport coords to world (canvas) coords
const toWorld = useCallback((screenX: number, screenY: number) => { const toWorld = useCallback((screenX: number, screenY: number) => {
@ -176,12 +190,26 @@ export const SimulatorCanvas = () => {
const touch = e.touches[0]; const touch = e.touches[0];
touchClickStartTimeRef.current = Date.now(); touchClickStartTimeRef.current = Date.now();
touchClickStartPosRef.current = { x: touch.clientX, y: touch.clientY }; touchClickStartPosRef.current = { x: touch.clientX, y: touch.clientY };
touchStartedOnPinRef.current = false;
// Identify what element was touched // Identify what element was touched
const target = document.elementFromPoint(touch.clientX, touch.clientY); const target = document.elementFromPoint(touch.clientX, touch.clientY);
const componentWrapper = target?.closest('[data-component-id]') as HTMLElement | null; const componentWrapper = target?.closest('[data-component-id]') as HTMLElement | null;
const boardOverlay = target?.closest('[data-board-overlay]') 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) { if (componentWrapper && !runningRef.current) {
// ── Single finger on a component: start drag ── // ── Single finger on a component: start drag ──
const componentId = componentWrapper.getAttribute('data-component-id'); const componentId = componentWrapper.getAttribute('data-component-id');
@ -278,6 +306,10 @@ export const SimulatorCanvas = () => {
y: world.y - touchDragOffsetRef.current.y, y: world.y - touchDragOffsetRef.current.y,
} as any); } 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; return;
} }
// ── Short tap on empty canvas: deselect ── // ── Short tap on empty canvas ──
if (changed) { if (changed) {
const elapsed = Date.now() - touchClickStartTimeRef.current; const elapsed = Date.now() - touchClickStartTimeRef.current;
const dx = changed.clientX - touchClickStartPosRef.current.x; const dx = changed.clientX - touchClickStartPosRef.current.x;
const dy = changed.clientY - touchClickStartPosRef.current.y; const dy = changed.clientY - touchClickStartPosRef.current.y;
const dist = Math.sqrt(dx * dx + dy * dy); const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 5 && elapsed < 300) { 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 }); el.addEventListener('touchstart', onTouchStart, { passive: false });
@ -782,6 +821,17 @@ export const SimulatorCanvas = () => {
</div> </div>
<div className="canvas-header-right"> <div className="canvas-header-right">
{/* Cancel wire creation — shown when a wire is being drawn (especially useful on mobile) */}
{wireInProgress && (
<button
className="cancel-wire-btn"
onClick={cancelWireCreation}
title="Cancel wire (Esc)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
Cancel wire
</button>
)}
{/* Zoom controls */} {/* Zoom controls */}
<div className="zoom-controls"> <div className="zoom-controls">
<button className="zoom-btn" onClick={() => handleWheel({ deltaY: 100, clientX: 0, clientY: 0, preventDefault: () => {} } as any)} title="Zoom out"> <button className="zoom-btn" onClick={() => handleWheel({ deltaY: 100, clientX: 0, clientY: 0, preventDefault: () => {} } as any)} title="Zoom out">

View File

@ -102,6 +102,15 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
[wire.id, setSelectedWire] [wire.id, setSelectedWire]
); );
// Handle wire selection via touch tap
const handleWireTouchEnd = useCallback(
(e: React.TouchEvent) => {
e.stopPropagation();
setSelectedWire(wire.id);
},
[wire.id, setSelectedWire]
);
// Handle segment hover // Handle segment hover
const handleMouseMove = useCallback( const handleMouseMove = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@ -170,6 +179,47 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
[dragState, isSelected, segments, wire, updateWire] [dragState, isSelected, segments, wire, updateWire]
); );
// Handle touch move for segment drag
const handleTouchMove = useCallback(
(e: React.TouchEvent) => {
if (!dragState) return;
const touch = e.touches[0];
if (!touch) return;
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
const svg = svgRef.current?.ownerSVGElement;
if (!svg) return;
const svgRect = svg.getBoundingClientRect();
const touchX = touch.clientX - svgRect.left;
const touchY = touch.clientY - svgRect.top;
const { segment, startMousePos, originalOrthoPoints } = dragState;
let offset = 0;
if (segment.orientation === 'horizontal') {
offset = touchY - startMousePos.y;
} else {
offset = touchX - startMousePos.x;
}
const newOrthoPoints = updateOrthogonalPointsForSegmentDrag(
originalOrthoPoints,
segment,
offset
);
setPreviewOrthoPoints(newOrthoPoints);
rafRef.current = null;
});
},
[dragState]
);
const handleSegmentMouseDown = useCallback( const handleSegmentMouseDown = useCallback(
(segment: WireSegment, e: React.MouseEvent) => { (segment: WireSegment, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@ -206,6 +256,32 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
[wire] [wire]
); );
// Handle touch start on a segment — begins drag on mobile
const handleSegmentTouchStart = useCallback(
(segment: WireSegment, e: React.TouchEvent) => {
e.stopPropagation();
const touch = e.touches[0];
if (!touch) return;
const svg = svgRef.current?.ownerSVGElement;
if (!svg) return;
const svgRect = svg.getBoundingClientRect();
const touchX = touch.clientX - svgRect.left;
const touchY = touch.clientY - svgRect.top;
const pathPoints = getPathPoints(wire);
const orthoPoints = generateOrthogonalPoints(pathPoints);
setDragState({
segment,
startMousePos: { x: touchX, y: touchY },
originalOrthoPoints: orthoPoints,
});
},
[wire]
);
const handleMouseUp = useCallback(() => { const handleMouseUp = useCallback(() => {
console.log('Mouse Up - Drag State:', { console.log('Mouse Up - Drag State:', {
hasDragState: !!dragState, hasDragState: !!dragState,
@ -247,6 +323,28 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
setPreviewOrthoPoints(null); setPreviewOrthoPoints(null);
}, [dragState, previewOrthoPoints, wire, updateWire]); }, [dragState, previewOrthoPoints, wire, updateWire]);
// Touch equivalent of handleMouseUp — finishes segment drag on mobile
const handleTouchEnd = useCallback(() => {
if (dragState && previewOrthoPoints) {
const GRID_SIZE = 20;
const snappedPoints = previewOrthoPoints.map((p) => ({
x: Math.round(p.x / GRID_SIZE) * GRID_SIZE,
y: Math.round(p.y / GRID_SIZE) * GRID_SIZE,
}));
const newControlPoints = orthogonalPointsToControlPoints(
snappedPoints,
wire.start,
wire.end
);
updateWire(wire.id, { controlPoints: newControlPoints });
}
setDragState(null);
setPreviewOrthoPoints(null);
}, [dragState, previewOrthoPoints, wire, updateWire]);
const handleMouseLeave = useCallback(() => { const handleMouseLeave = useCallback(() => {
if (!dragState) { if (!dragState) {
setHoveredSegment(null); setHoveredSegment(null);
@ -282,11 +380,14 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
<g <g
ref={svgRef} ref={svgRef}
className="wire-group" className="wire-group"
data-no-pan="true"
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
> >
{/* Invisible thick path for easier clicking */} {/* Invisible thick path for easier clicking/tapping */}
<path <path
d={path} d={path}
stroke="transparent" stroke="transparent"
@ -294,6 +395,7 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
fill="none" fill="none"
style={{ pointerEvents: 'stroke', cursor: 'pointer' }} style={{ pointerEvents: 'stroke', cursor: 'pointer' }}
onClick={handleWireClick} onClick={handleWireClick}
onTouchEnd={handleWireTouchEnd}
/> />
{/* Background erasing path for visual crossing effect */} {/* Background erasing path for visual crossing effect */}
@ -357,6 +459,7 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
pointerEvents: 'stroke', pointerEvents: 'stroke',
}} }}
onMouseDown={(e) => handleSegmentMouseDown(segment, e)} onMouseDown={(e) => handleSegmentMouseDown(segment, e)}
onTouchStart={(e) => handleSegmentTouchStart(segment, e)}
/> />
{/* Visual drag handle at midpoint when hovering */} {/* Visual drag handle at midpoint when hovering */}