feat: add zoom support for touch interactions and enhance pin hit targets

pull/77/head
David Montero Crespo 2026-03-28 00:31:00 -03:00
parent f127ef6cbe
commit 4e3d5090e2
5 changed files with 102 additions and 10 deletions

View File

@ -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}
/>
</>
);

View File

@ -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<PinOverlayProps> = ({
@ -33,6 +47,7 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
showPins,
wrapperOffsetX = 4,
wrapperOffsetY = 6,
zoom = 1,
}) => {
const [pins, setPins] = useState<PinInfo[]>([]);
@ -56,6 +71,13 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
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 (
<div
style={{
@ -67,7 +89,6 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
}}
>
{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<PinOverlayProps> = ({
}}
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)';

View File

@ -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}
/>
)}
</React.Fragment>
@ -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}
/>
))}

View File

@ -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<WireLayerProps> = ({
@ -25,6 +29,7 @@ export const WireLayer: React.FC<WireLayerProps> = ({
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<WireLayerProps> = ({
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)}
/>
))}

View File

@ -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<string, unknown>): void;
type GtagFn = (command: 'event', eventName: string, eventParams?: Record<string, unknown>) => void;
function fireEvent(eventName: string, params: Record<string, string | number | boolean>): void {
const gtag = (window as unknown as { gtag?: GtagFn }).gtag;
if (typeof gtag === 'function') {
gtag('event', eventName, params);
}