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:
parent
357c43ae0d
commit
b821fe9747
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -63,13 +63,18 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
|
|||
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 (
|
||||
<div
|
||||
key={pin.name}
|
||||
onClick={(e) => {
|
||||
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`,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 = () => {
|
|||
</div>
|
||||
|
||||
<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 */}
|
||||
<div className="zoom-controls">
|
||||
<button className="zoom-btn" onClick={() => handleWheel({ deltaY: 100, clientX: 0, clientY: 0, preventDefault: () => {} } as any)} title="Zoom out">
|
||||
|
|
|
|||
|
|
@ -102,6 +102,15 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
|
|||
[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
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
|
|
@ -170,6 +179,47 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
|
|||
[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(
|
||||
(segment: WireSegment, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -206,6 +256,32 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
|
|||
[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(() => {
|
||||
console.log('Mouse Up - Drag State:', {
|
||||
hasDragState: !!dragState,
|
||||
|
|
@ -247,6 +323,28 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
|
|||
setPreviewOrthoPoints(null);
|
||||
}, [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(() => {
|
||||
if (!dragState) {
|
||||
setHoveredSegment(null);
|
||||
|
|
@ -282,11 +380,14 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
|
|||
<g
|
||||
ref={svgRef}
|
||||
className="wire-group"
|
||||
data-no-pan="true"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Invisible thick path for easier clicking */}
|
||||
{/* Invisible thick path for easier clicking/tapping */}
|
||||
<path
|
||||
d={path}
|
||||
stroke="transparent"
|
||||
|
|
@ -294,6 +395,7 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
|
|||
fill="none"
|
||||
style={{ pointerEvents: 'stroke', cursor: 'pointer' }}
|
||||
onClick={handleWireClick}
|
||||
onTouchEnd={handleWireTouchEnd}
|
||||
/>
|
||||
|
||||
{/* Background erasing path for visual crossing effect */}
|
||||
|
|
@ -357,6 +459,7 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
|
|||
pointerEvents: 'stroke',
|
||||
}}
|
||||
onMouseDown={(e) => handleSegmentMouseDown(segment, e)}
|
||||
onTouchStart={(e) => handleSegmentTouchStart(segment, e)}
|
||||
/>
|
||||
|
||||
{/* Visual drag handle at midpoint when hovering */}
|
||||
|
|
|
|||
Loading…
Reference in New Issue