feat: add zoom support for touch interactions and enhance pin hit targets
parent
f127ef6cbe
commit
4e3d5090e2
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)';
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue