feat: add zoom support for touch interactions and enhance pin hit targets
parent
f127ef6cbe
commit
4e3d5090e2
|
|
@ -40,6 +40,7 @@ interface BoardOnCanvasProps {
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
onMouseDown: (e: React.MouseEvent) => void;
|
onMouseDown: (e: React.MouseEvent) => void;
|
||||||
onPinClick: (componentId: string, pinName: string, x: number, y: number) => void;
|
onPinClick: (componentId: string, pinName: string, x: number, y: number) => void;
|
||||||
|
zoom?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BoardOnCanvas = ({
|
export const BoardOnCanvas = ({
|
||||||
|
|
@ -49,6 +50,7 @@ export const BoardOnCanvas = ({
|
||||||
isActive = false,
|
isActive = false,
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
onPinClick,
|
onPinClick,
|
||||||
|
zoom = 1,
|
||||||
}: BoardOnCanvasProps) => {
|
}: BoardOnCanvasProps) => {
|
||||||
const { id, boardKind, x, y } = board;
|
const { id, boardKind, x, y } = board;
|
||||||
const size = BOARD_SIZE[boardKind] ?? { w: 300, h: 200 };
|
const size = BOARD_SIZE[boardKind] ?? { w: 300, h: 200 };
|
||||||
|
|
@ -156,6 +158,7 @@ export const BoardOnCanvas = ({
|
||||||
showPins={true}
|
showPins={true}
|
||||||
wrapperOffsetX={0}
|
wrapperOffsetX={0}
|
||||||
wrapperOffsetY={0}
|
wrapperOffsetY={0}
|
||||||
|
zoom={zoom}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,22 @@
|
||||||
*
|
*
|
||||||
* Renders clickable pin indicators over components to enable wire creation.
|
* Renders clickable pin indicators over components to enable wire creation.
|
||||||
* Shows when hovering over a component or when creating a wire.
|
* 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';
|
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 {
|
interface PinInfo {
|
||||||
name: string;
|
name: string;
|
||||||
x: number; // CSS pixels
|
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. */
|
/** 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;
|
wrapperOffsetX?: number;
|
||||||
wrapperOffsetY?: number;
|
wrapperOffsetY?: number;
|
||||||
|
/** Current canvas zoom level — used to keep touch targets usable at any zoom */
|
||||||
|
zoom?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PinOverlay: React.FC<PinOverlayProps> = ({
|
export const PinOverlay: React.FC<PinOverlayProps> = ({
|
||||||
|
|
@ -33,6 +47,7 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
|
||||||
showPins,
|
showPins,
|
||||||
wrapperOffsetX = 4,
|
wrapperOffsetX = 4,
|
||||||
wrapperOffsetY = 6,
|
wrapperOffsetY = 6,
|
||||||
|
zoom = 1,
|
||||||
}) => {
|
}) => {
|
||||||
const [pins, setPins] = useState<PinInfo[]>([]);
|
const [pins, setPins] = useState<PinInfo[]>([]);
|
||||||
|
|
||||||
|
|
@ -56,6 +71,13 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
|
||||||
return null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -67,7 +89,6 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{pins.map((pin, index) => {
|
{pins.map((pin, index) => {
|
||||||
// Pin coordinates are already in CSS pixels
|
|
||||||
const pinX = pin.x;
|
const pinX = pin.x;
|
||||||
const pinY = pin.y;
|
const pinY = pin.y;
|
||||||
|
|
||||||
|
|
@ -81,20 +102,22 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
|
||||||
}}
|
}}
|
||||||
onTouchEnd={(e) => {
|
onTouchEnd={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
onPinClick(componentId, pin.name, componentX + wrapperOffsetX + pinX, componentY + wrapperOffsetY + pinY);
|
onPinClick(componentId, pin.name, componentX + wrapperOffsetX + pinX, componentY + wrapperOffsetY + pinY);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: `${pinX - 6}px`,
|
left: `${pinX - pinHalf}px`,
|
||||||
top: `${pinY - 6}px`,
|
top: `${pinY - pinHalf}px`,
|
||||||
width: '12px',
|
width: `${pinSize}px`,
|
||||||
height: '12px',
|
height: `${pinSize}px`,
|
||||||
borderRadius: '3px',
|
borderRadius: '3px',
|
||||||
backgroundColor: 'rgba(0, 200, 255, 0.8)',
|
backgroundColor: 'rgba(0, 200, 255, 0.8)',
|
||||||
border: '1.5px solid white',
|
border: '1.5px solid white',
|
||||||
cursor: 'crosshair',
|
cursor: 'crosshair',
|
||||||
pointerEvents: 'all',
|
pointerEvents: 'all',
|
||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
|
touchAction: 'none',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.backgroundColor = 'rgba(0, 255, 100, 1)';
|
e.currentTarget.style.backgroundColor = 'rgba(0, 255, 100, 1)';
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ import {
|
||||||
renderedToWaypoints,
|
renderedToWaypoints,
|
||||||
renderedPointsToPath,
|
renderedPointsToPath,
|
||||||
} from '../../utils/wireHitDetection';
|
} 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 { ComponentMetadata } from '../../types/component-metadata';
|
||||||
import type { BoardKind } from '../../types/board';
|
import type { BoardKind } from '../../types/board';
|
||||||
import { BOARD_KIND_LABELS } from '../../types/board';
|
import { BOARD_KIND_LABELS } from '../../types/board';
|
||||||
|
|
@ -396,6 +399,18 @@ export const SimulatorCanvas = () => {
|
||||||
if (e.touches.length !== 1) return;
|
if (e.touches.length !== 1) return;
|
||||||
const touch = e.touches[0];
|
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 ──
|
// ── Wire preview: update position as finger moves ──
|
||||||
if (wireInProgressRef.current && !isPanningRef.current && !touchDraggedComponentIdRef.current) {
|
if (wireInProgressRef.current && !isPanningRef.current && !touchDraggedComponentIdRef.current) {
|
||||||
const world = toWorld(touch.clientX, touch.clientY);
|
const world = toWorld(touch.clientX, touch.clientY);
|
||||||
|
|
@ -464,6 +479,24 @@ export const SimulatorCanvas = () => {
|
||||||
|
|
||||||
if (e.touches.length > 0) return; // Still fingers on screen
|
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 ──
|
// ── Finish panning ──
|
||||||
let wasPanning = false;
|
let wasPanning = false;
|
||||||
if (isPanningRef.current) {
|
if (isPanningRef.current) {
|
||||||
|
|
@ -480,7 +513,7 @@ export const SimulatorCanvas = () => {
|
||||||
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);
|
||||||
const isShortTap = dist < 10 && elapsed < 400;
|
const isShortTap = dist < 20 && elapsed < 400;
|
||||||
|
|
||||||
// If we actually panned (moved significantly), don't process as tap
|
// If we actually panned (moved significantly), don't process as tap
|
||||||
if (wasPanning && !isShortTap) return;
|
if (wasPanning && !isShortTap) return;
|
||||||
|
|
@ -530,7 +563,8 @@ export const SimulatorCanvas = () => {
|
||||||
if (isShortTap) {
|
if (isShortTap) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const world = toWorld(changed.clientX, changed.clientY);
|
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);
|
const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold);
|
||||||
|
|
||||||
// Double-tap → delete wire
|
// Double-tap → delete wire
|
||||||
|
|
@ -961,6 +995,28 @@ export const SimulatorCanvas = () => {
|
||||||
[selectedWireId],
|
[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
|
// Zoom centered on cursor
|
||||||
const handleWheel = (e: React.WheelEvent) => {
|
const handleWheel = (e: React.WheelEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -1135,6 +1191,7 @@ export const SimulatorCanvas = () => {
|
||||||
componentY={component.y}
|
componentY={component.y}
|
||||||
onPinClick={handlePinClick}
|
onPinClick={handlePinClick}
|
||||||
showPins={showPinsForComponent}
|
showPins={showPinsForComponent}
|
||||||
|
zoom={zoom}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|
@ -1325,6 +1382,7 @@ export const SimulatorCanvas = () => {
|
||||||
segmentDragPreview={segmentDragPreview}
|
segmentDragPreview={segmentDragPreview}
|
||||||
segmentHandles={segmentHandles}
|
segmentHandles={segmentHandles}
|
||||||
onHandleMouseDown={handleHandleMouseDown}
|
onHandleMouseDown={handleHandleMouseDown}
|
||||||
|
onHandleTouchStart={handleHandleTouchStart}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* All boards on canvas */}
|
{/* All boards on canvas */}
|
||||||
|
|
@ -1343,6 +1401,7 @@ export const SimulatorCanvas = () => {
|
||||||
setDragOffset({ x: world.x - board.x, y: world.y - board.y });
|
setDragOffset({ x: world.x - board.x, y: world.y - board.y });
|
||||||
}}
|
}}
|
||||||
onPinClick={handlePinClick}
|
onPinClick={handlePinClick}
|
||||||
|
zoom={zoom}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { useSimulatorStore } from '../../store/useSimulatorStore';
|
||||||
import { WireRenderer } from './WireRenderer';
|
import { WireRenderer } from './WireRenderer';
|
||||||
import { WireInProgressRenderer } from './WireInProgressRenderer';
|
import { WireInProgressRenderer } from './WireInProgressRenderer';
|
||||||
|
|
||||||
|
const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0);
|
||||||
|
|
||||||
export interface SegmentHandle {
|
export interface SegmentHandle {
|
||||||
segIndex: number;
|
segIndex: number;
|
||||||
axis: 'horizontal' | 'vertical';
|
axis: 'horizontal' | 'vertical';
|
||||||
|
|
@ -18,6 +20,8 @@ interface WireLayerProps {
|
||||||
segmentHandles: SegmentHandle[];
|
segmentHandles: SegmentHandle[];
|
||||||
/** Called when user starts dragging a handle (passes segIndex) */
|
/** Called when user starts dragging a handle (passes segIndex) */
|
||||||
onHandleMouseDown: (e: React.MouseEvent, segIndex: number) => void;
|
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> = ({
|
export const WireLayer: React.FC<WireLayerProps> = ({
|
||||||
|
|
@ -25,6 +29,7 @@ export const WireLayer: React.FC<WireLayerProps> = ({
|
||||||
segmentDragPreview,
|
segmentDragPreview,
|
||||||
segmentHandles,
|
segmentHandles,
|
||||||
onHandleMouseDown,
|
onHandleMouseDown,
|
||||||
|
onHandleTouchStart,
|
||||||
}) => {
|
}) => {
|
||||||
const wires = useSimulatorStore((s) => s.wires);
|
const wires = useSimulatorStore((s) => s.wires);
|
||||||
const wireInProgress = useSimulatorStore((s) => s.wireInProgress);
|
const wireInProgress = useSimulatorStore((s) => s.wireInProgress);
|
||||||
|
|
@ -64,12 +69,13 @@ export const WireLayer: React.FC<WireLayerProps> = ({
|
||||||
key={handle.segIndex}
|
key={handle.segIndex}
|
||||||
cx={handle.mx}
|
cx={handle.mx}
|
||||||
cy={handle.my}
|
cy={handle.my}
|
||||||
r={7}
|
r={isTouchDevice ? 14 : 7}
|
||||||
fill="white"
|
fill="white"
|
||||||
stroke="#007acc"
|
stroke="#007acc"
|
||||||
strokeWidth={2}
|
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)}
|
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.
|
* 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 {
|
function fireEvent(eventName: string, params: Record<string, string | number | boolean>): void {
|
||||||
|
const gtag = (window as unknown as { gtag?: GtagFn }).gtag;
|
||||||
if (typeof gtag === 'function') {
|
if (typeof gtag === 'function') {
|
||||||
gtag('event', eventName, params);
|
gtag('event', eventName, params);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue