feat: improve mobile wiring UX with pinch-zoom preserve and crosshair

- Pinch-zoom no longer cancels wire-in-progress (preview freezes during
  2-finger gesture, resumes after)
- Full-canvas crosshair guides (8000px dashed lines) at cursor position
  during wire creation for touch precision alignment
- PinOverlay touch interaction improvements
- Segment handle scaling inverse to zoom for constant screen size
master
a2nr 2026-04-09 09:52:08 +07:00
parent ea82602102
commit cd85b78b75
6 changed files with 381 additions and 74 deletions

View File

@ -71,12 +71,15 @@ 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
// Visual indicator stays small (PIN_VISUAL world px).
// On touch devices the *hit area* is enlarged inversely to zoom so the
// screen-space tap target stays at least TOUCH_MIN_SCREEN_PX, but the
// visible dot stays at PIN_VISUAL so it doesn't dwarf the components.
const hitSize = isTouchDevice
? Math.max(PIN_VISUAL, TOUCH_MIN_SCREEN_PX / zoom)
: PIN_VISUAL;
const pinHalf = pinSize / 2;
const hitHalf = hitSize / 2;
const visualHalf = PIN_VISUAL / 2;
return (
<div
@ -107,28 +110,47 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
}}
style={{
position: 'absolute',
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',
left: `${pinX - hitHalf}px`,
top: `${pinY - hitHalf}px`,
width: `${hitSize}px`,
height: `${hitSize}px`,
cursor: 'crosshair',
pointerEvents: 'all',
transition: 'all 0.15s',
touchAction: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(0, 255, 100, 1)';
e.currentTarget.style.transform = 'scale(1.4)';
const dot = e.currentTarget.firstElementChild as HTMLElement;
if (dot) {
dot.style.backgroundColor = 'rgba(0, 255, 100, 1)';
dot.style.transform = 'translate(-50%, -50%) scale(1.4)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(0, 200, 255, 0.8)';
e.currentTarget.style.transform = 'scale(1)';
const dot = e.currentTarget.firstElementChild as HTMLElement;
if (dot) {
dot.style.backgroundColor = 'rgba(0, 200, 255, 0.8)';
dot.style.transform = 'translate(-50%, -50%) scale(1)';
}
}}
title={pin.name}
/>
>
{/* Small visible dot — stays at PIN_VISUAL regardless of zoom */}
<div
style={{
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
width: `${PIN_VISUAL}px`,
height: `${PIN_VISUAL}px`,
borderRadius: '3px',
backgroundColor: 'rgba(0, 200, 255, 0.8)',
border: '1.5px solid white',
transition: 'all 0.15s',
pointerEvents: 'none',
}}
/>
</div>
);
})}
</div>

View File

@ -16,6 +16,7 @@ import { PartSimulationRegistry } from '../../simulation/parts';
import { PinOverlay } from './PinOverlay';
import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping';
import { autoWireColor, WIRE_KEY_COLORS } from '../../utils/wireUtils';
import { findClosestPin, getAllPinPositions } from '../../utils/pinPositionCalculator';
import {
findWireNearPoint,
getRenderedPoints,
@ -80,6 +81,10 @@ export const SimulatorCanvas = () => {
const setSelectedWire = useSimulatorStore((s) => s.setSelectedWire);
const removeWire = useSimulatorStore((s) => s.removeWire);
const updateWire = useSimulatorStore((s) => s.updateWire);
const undoWire = useSimulatorStore((s) => s.undoWire);
const redoWire = useSimulatorStore((s) => s.redoWire);
const canUndoWire = useSimulatorStore((s) => s.wireUndoStack.length > 0);
const canRedoWire = useSimulatorStore((s) => s.wireRedoStack.length > 0);
const wires = useSimulatorStore((s) => s.wires);
// Oscilloscope
@ -90,6 +95,13 @@ export const SimulatorCanvas = () => {
const esp32CrashBoardId = useSimulatorStore((s) => s.esp32CrashBoardId);
const dismissEsp32Crash = useSimulatorStore((s) => s.dismissEsp32Crash);
// Board built-in LED: subscribe directly to PinManager pin 13 (independent of led-builtin component)
const [boardLed13, setBoardLed13] = useState(false);
useEffect(() => {
if (!pinManager) return;
return pinManager.onPinChange(13, (_pin, state) => setBoardLed13(state));
}, [pinManager]);
// Component picker modal
const [showComponentPicker, setShowComponentPicker] = useState(false);
const [registry] = useState(() => ComponentRegistry.getInstance());
@ -198,8 +210,27 @@ export const SimulatorCanvas = () => {
selectedWireIdRef.current = selectedWireId;
const touchPassthroughRef = useRef(false);
const touchOnPinRef = useRef(false);
const touchOnBannerRef = useRef(false);
const lastTapTimeRef = useRef(0);
// ── Touch aiming state for wire creation ───────────────────────────────
// Full crosshair-based wire creation on touch devices:
// idle → long press → aiming_start (crosshair, no wire yet)
// aiming_start → release on pin → wire_started (wire begins from pin)
// aiming_start → release empty → idle (cancelled)
// wire_started → long press → aiming_end (crosshair, wire follows)
// aiming_end → release on pin → idle (wire finished, snapped)
// aiming_end → release empty → wire_started (waypoint added)
const AIMING_LONG_PRESS_MS = 400;
const AIMING_OFFSET_Y = -50; // world-px offset (crosshair above finger)
const AIMING_PIN_SNAP_DIST = 40; // world-px: max distance to snap to a pin on release
type WireAimingPhase = 'idle' | 'aiming_start' | 'wire_started' | 'aiming_end';
const wireAimingPhaseRef = useRef<WireAimingPhase>('idle');
const wireAimingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentTouchRef = useRef({ x: 0, y: 0 }); // latest screen position for timeout callback
const [wireAiming, setWireAiming] = useState(false); // React state for rendering (crosshair visible)
const [aimPosition, setAimPosition] = useState<{ x: number; y: number } | null>(null); // world coords of crosshair
// Convert viewport coords to world (canvas) coords
const toWorld = useCallback((screenX: number, screenY: number) => {
const rect = canvasRef.current?.getBoundingClientRect();
@ -210,6 +241,44 @@ export const SimulatorCanvas = () => {
};
}, []);
/** Find the closest pin across ALL components and boards to a world position. */
// Component wrappers add padding/border offset to pin positions.
// PinOverlay uses wrapperOffsetX=4, wrapperOffsetY=6 for components, 0 for boards.
const COMP_WRAPPER_OFFSET_X = 4;
const COMP_WRAPPER_OFFSET_Y = 6;
const findNearestPin = useCallback((worldX: number, worldY: number, maxDist: number) => {
let best: { componentId: string; pinName: string; x: number; y: number; dist: number } | null = null;
// Check component pins (offset by wrapper padding)
for (const comp of componentsRef.current) {
const pin = findClosestPin(
comp.id, comp.x + COMP_WRAPPER_OFFSET_X, comp.y + COMP_WRAPPER_OFFSET_Y,
worldX, worldY, maxDist,
);
if (pin) {
const d = Math.sqrt((pin.x - worldX) ** 2 + (pin.y - worldY) ** 2);
if (!best || d < best.dist) {
best = { componentId: comp.id, pinName: pin.name, x: pin.x, y: pin.y, dist: d };
}
}
}
// Check board pins (no wrapper offset)
const storeBoards = useSimulatorStore.getState().boards;
for (const board of storeBoards) {
const pin = findClosestPin(board.id, board.x, board.y, worldX, worldY, maxDist);
if (pin) {
const d = Math.sqrt((pin.x - worldX) ** 2 + (pin.y - worldY) ** 2);
if (!best || d < best.dist) {
best = { componentId: board.id, pinName: pin.name, x: pin.x, y: pin.y, dist: d };
}
}
}
return best;
}, []);
// Initialize simulator on mount
useEffect(() => {
initSimulator();
@ -262,14 +331,18 @@ export const SimulatorCanvas = () => {
// Reset per-gesture flags
touchOnPinRef.current = false;
touchPassthroughRef.current = false;
touchOnBannerRef.current = false;
pinchStartDistRef.current = 0;
if (e.touches.length === 2) {
e.preventDefault();
// Cancel wire in progress on two-finger gesture
if (wireInProgressRef.current) {
useSimulatorStore.getState().cancelWireCreation();
}
// Preserve wire-in-progress during pinch-zoom — don't cancel it.
// The wire preview will freeze while pinching (touchMove only
// updates wire when touches===1) and resume after zoom ends.
// Cancel aiming timer (pinch is zoom, not aiming)
if (wireAimingTimerRef.current) { clearTimeout(wireAimingTimerRef.current); wireAimingTimerRef.current = null; }
// Cancel any active drag/pan and prepare zoom
isPanningRef.current = false;
touchDraggedComponentIdRef.current = null;
@ -301,6 +374,12 @@ export const SimulatorCanvas = () => {
return;
}
// ── 1b. Banner buttons (Cancel, Delete) → let browser synthesize click ──
if (target?.closest('.wire-mode-banner') || target?.closest('.wire-selected-banner')) {
touchOnBannerRef.current = true;
return; // Don't preventDefault → browser fires click on button
}
// ── 2. Interactive web component during simulation → let browser synthesize mouse events ──
// (potentiometer knobs, button presses, etc. need mousedown/mouseup synthesis)
// touch-action:none on .canvas-content already prevents browser scroll/zoom.
@ -317,15 +396,47 @@ export const SimulatorCanvas = () => {
touchClickStartTimeRef.current = Date.now();
touchClickStartPosRef.current = { x: touch.clientX, y: touch.clientY };
currentTouchRef.current = { x: touch.clientX, y: touch.clientY };
// ── 3. Wire in progress → track for waypoint, update preview ──
if (wireInProgressRef.current) {
// ── 3. Touch aiming for wire creation ──
// Any phase that uses aiming: cancel previous timer first
if (wireAimingTimerRef.current) { clearTimeout(wireAimingTimerRef.current); wireAimingTimerRef.current = null; }
const aimPhase = wireAimingPhaseRef.current;
// Already in an aiming phase (aiming_start or aiming_end) → touch updates crosshair immediately
if (aimPhase === 'aiming_start' || aimPhase === 'aiming_end') {
const world = toWorld(touch.clientX, touch.clientY);
useSimulatorStore.getState().updateWireInProgress(world.x, world.y);
// Don't start pan/drag — let touchmove update wire preview, touchend add waypoint
if (aimPhase === 'aiming_end') {
useSimulatorStore.getState().updateWireInProgress(world.x, world.y + AIMING_OFFSET_Y);
}
return;
}
// wire_started → long press to aim for endpoint, short drag = pan
if (aimPhase === 'wire_started') {
wireAimingTimerRef.current = setTimeout(() => {
wireAimingPhaseRef.current = 'aiming_end';
setWireAiming(true);
if (navigator.vibrate) navigator.vibrate(30);
const world = toWorld(currentTouchRef.current.x, currentTouchRef.current.y);
const aimX = world.x;
const aimY = world.y + AIMING_OFFSET_Y;
setAimPosition({ x: aimX, y: aimY });
useSimulatorStore.getState().updateWireInProgress(aimX, aimY);
}, AIMING_LONG_PRESS_MS);
isPanningRef.current = true;
panStartRef.current = {
mouseX: touch.clientX,
mouseY: touch.clientY,
panX: panRef.current.x,
panY: panRef.current.y,
};
return;
}
// idle: aiming timer is set below (only for empty canvas, not components)
// ── 4. Component detection ──
const componentWrapper = target?.closest('[data-component-id]') as HTMLElement | null;
const boardOverlay = target?.closest('[data-board-overlay]') as HTMLElement | null;
@ -367,7 +478,16 @@ export const SimulatorCanvas = () => {
};
}
} else {
// ── 6. Empty canvas → start pan ──
// ── 6. Empty canvas → start pan + long press starts aiming (idle phase only) ──
if (aimPhase === 'idle') {
wireAimingTimerRef.current = setTimeout(() => {
wireAimingPhaseRef.current = 'aiming_start';
setWireAiming(true);
if (navigator.vibrate) navigator.vibrate(30);
const world = toWorld(currentTouchRef.current.x, currentTouchRef.current.y);
setAimPosition({ x: world.x, y: world.y + AIMING_OFFSET_Y });
}, AIMING_LONG_PRESS_MS);
}
isPanningRef.current = true;
panStartRef.current = {
mouseX: touch.clientX,
@ -428,13 +548,31 @@ export const SimulatorCanvas = () => {
return;
}
// ── Wire preview: update position as finger moves ──
if (wireInProgressRef.current && !isPanningRef.current && !touchDraggedComponentIdRef.current) {
// ── Touch aiming: crosshair follows finger ──
currentTouchRef.current = { x: touch.clientX, y: touch.clientY };
const aimPhase = wireAimingPhaseRef.current;
if (aimPhase === 'aiming_start' || aimPhase === 'aiming_end') {
const world = toWorld(touch.clientX, touch.clientY);
useSimulatorStore.getState().updateWireInProgress(world.x, world.y);
const aimX = world.x;
const aimY = world.y + AIMING_OFFSET_Y;
setAimPosition({ x: aimX, y: aimY });
if (aimPhase === 'aiming_end') {
useSimulatorStore.getState().updateWireInProgress(aimX, aimY);
}
return;
}
// idle or wire_started with pending timer: cancel if finger moves (pan gesture)
if (wireAimingTimerRef.current) {
const dx = touch.clientX - touchClickStartPosRef.current.x;
const dy = touch.clientY - touchClickStartPosRef.current.y;
if (Math.sqrt(dx * dx + dy * dy) > 10) {
clearTimeout(wireAimingTimerRef.current);
wireAimingTimerRef.current = null;
}
}
if (isPanningRef.current) {
// ── Single finger pan ──
const dx = touch.clientX - panStartRef.current.mouseX;
@ -484,6 +622,11 @@ export const SimulatorCanvas = () => {
e.preventDefault();
return;
}
// Banner touch: let browser fire click on button
if (touchOnBannerRef.current) {
touchOnBannerRef.current = false;
return; // No preventDefault → click fires on button
}
e.preventDefault();
@ -514,18 +657,73 @@ export const SimulatorCanvas = () => {
return;
}
// ── Touch aiming release (MUST come before pan check — aiming sets isPanning too) ──
if (wireAimingTimerRef.current) { clearTimeout(wireAimingTimerRef.current); wireAimingTimerRef.current = null; }
const aimPhase = wireAimingPhaseRef.current;
const changed = e.changedTouches[0];
if (!changed) return;
if (aimPhase === 'aiming_start') {
// Release after first aim: find pin to start wire
isPanningRef.current = false;
const world = toWorld(changed.clientX, changed.clientY);
const aimX = world.x;
const aimY = world.y + AIMING_OFFSET_Y;
const snapDist = AIMING_PIN_SNAP_DIST / zoomRef.current;
const nearPin = findNearestPin(aimX, aimY, snapDist);
setWireAiming(false); setAimPosition(null);
if (nearPin) {
// Start wire from this pin
startWireCreation(
{ componentId: nearPin.componentId, pinName: nearPin.pinName, x: nearPin.x, y: nearPin.y },
autoWireColor(nearPin.pinName),
);
wireAimingPhaseRef.current = 'wire_started';
} else {
// No pin found — cancel
wireAimingPhaseRef.current = 'idle';
}
return;
}
if (aimPhase === 'aiming_end') {
// Release after second aim: snap to pin (finish) or add waypoint
isPanningRef.current = false;
const world = toWorld(changed.clientX, changed.clientY);
const aimX = world.x;
const aimY = world.y + AIMING_OFFSET_Y;
const snapDist = AIMING_PIN_SNAP_DIST / zoomRef.current;
const nearPin = findNearestPin(aimX, aimY, snapDist);
setWireAiming(false); setAimPosition(null);
if (nearPin) {
// Finish wire at this pin
finishWireCreation({ componentId: nearPin.componentId, pinName: nearPin.pinName, x: nearPin.x, y: nearPin.y });
trackCreateWire();
wireAimingPhaseRef.current = 'idle';
} else {
// No pin: add waypoint, stay in wire_started for next aim
useSimulatorStore.getState().addWireWaypoint(aimX, aimY);
wireAimingPhaseRef.current = 'wire_started';
}
return;
}
// wire_started but not aiming (short tap): skip — user must long-press to aim
if (aimPhase === 'wire_started') {
isPanningRef.current = false;
return;
}
// ── Finish panning ──
let wasPanning = false;
if (isPanningRef.current) {
isPanningRef.current = false;
setPan({ ...panRef.current });
wasPanning = true;
// Don't return — fall through so short taps can select wires
}
const changed = e.changedTouches[0];
if (!changed) return;
const elapsed = Date.now() - touchClickStartTimeRef.current;
const dx = changed.clientX - touchClickStartPosRef.current.x;
const dy = changed.clientY - touchClickStartPosRef.current.y;
@ -541,14 +739,10 @@ export const SimulatorCanvas = () => {
if (isShortTap) {
if (touchId.startsWith('__board__:')) {
// Short tap on board → make it the active board
const boardId = touchId.slice('__board__:'.length);
useSimulatorStore.getState().setActiveBoardId(boardId);
} else if (touchId !== '__board__') {
// Short tap on component → open property dialog or sensor panel
const component = componentsRef.current.find(
(c) => c.id === touchId
);
const component = componentsRef.current.find((c) => c.id === touchId);
if (component) {
if (runningRef.current && SENSOR_CONTROLS[component.metadataId] !== undefined) {
setSensorControlComponentId(touchId);
@ -567,15 +761,6 @@ export const SimulatorCanvas = () => {
return;
}
// ── Wire in progress: short tap adds waypoint ──
if (wireInProgressRef.current) {
if (isShortTap) {
const world = toWorld(changed.clientX, changed.clientY);
useSimulatorStore.getState().addWireWaypoint(world.x, world.y);
}
return;
}
// ── Short tap on empty canvas: wire selection + double-tap wire deletion ──
if (isShortTap) {
const now = Date.now();
@ -1082,6 +1267,10 @@ export const SimulatorCanvas = () => {
// Finish wire: connect to this pin
finishWireCreation({ componentId, pinName, x, y });
trackCreateWire();
// Reset aiming state
wireAimingPhaseRef.current = 'idle';
setWireAiming(false); setAimPosition(null);
if (wireAimingTimerRef.current) { clearTimeout(wireAimingTimerRef.current); wireAimingTimerRef.current = null; }
} else {
// Start wire: auto-detect color from pin name
startWireCreation({ componentId, pinName, x, y }, autoWireColor(pinName));
@ -1091,9 +1280,23 @@ export const SimulatorCanvas = () => {
// Keyboard handlers for wires
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Escape → cancel in-progress wire
if (e.key === 'Escape' && wireInProgress) {
cancelWireCreation();
// Escape → cancel in-progress wire or aiming
if (e.key === 'Escape' && (wireInProgress || wireAimingPhaseRef.current !== 'idle')) {
if (wireInProgress) cancelWireCreation();
wireAimingPhaseRef.current = 'idle';
setWireAiming(false); setAimPosition(null);
if (wireAimingTimerRef.current) { clearTimeout(wireAimingTimerRef.current); wireAimingTimerRef.current = null; }
return;
}
// Ctrl+Z → undo wire, Ctrl+Shift+Z → redo wire
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
undoWire();
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'Z' || (e.key === 'z' && e.shiftKey))) {
e.preventDefault();
redoWire();
return;
}
// Delete / Backspace → remove selected wire
@ -1114,7 +1317,7 @@ export const SimulatorCanvas = () => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [wireInProgress, cancelWireCreation, selectedWireId, removeWire, setWireInProgressColor, updateWire]);
}, [wireInProgress, cancelWireCreation, selectedWireId, removeWire, setWireInProgressColor, updateWire, undoWire, redoWire]);
// Recalculate wire positions when components change (e.g., when loading an example)
useEffect(() => {
@ -1339,6 +1542,16 @@ export const SimulatorCanvas = () => {
</div>
<div className="canvas-header-right">
{/* Undo / Redo wire */}
<div className="undo-controls">
<button className="zoom-btn" onClick={undoWire} disabled={!canUndoWire} title="Undo wire (Ctrl+Z)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" /></svg>
</button>
<button className="zoom-btn" onClick={redoWire} disabled={!canRedoWire} title="Redo wire (Ctrl+Shift+Z)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.13-9.36L23 10" /></svg>
</button>
</div>
{/* Zoom controls */}
<div className="zoom-controls">
<button className="zoom-btn" onClick={() => handleWheel({ deltaY: 100, clientX: 0, clientY: 0, preventDefault: () => {} } as any)} title="Zoom out">
@ -1454,6 +1667,8 @@ export const SimulatorCanvas = () => {
segmentHandles={segmentHandles}
onHandleMouseDown={handleHandleMouseDown}
onHandleTouchStart={handleHandleTouchStart}
isAiming={wireAiming}
zoom={zoom}
/>
{/* All boards on canvas */}
@ -1463,7 +1678,7 @@ export const SimulatorCanvas = () => {
board={board}
running={running}
isActive={board.id === activeBoardId}
led13={Boolean(components.find((c) => c.id === 'led-builtin')?.properties.state)}
led13={boardLed13}
onMouseDown={(e) => {
setClickStartTime(Date.now());
setClickStartPos({ x: e.clientX, y: e.clientY });
@ -1483,15 +1698,45 @@ export const SimulatorCanvas = () => {
{/* Components using wokwi-elements */}
<div className="components-area">{registryLoaded && components.map(renderComponent)}</div>
{/* Standalone aiming crosshair (before wire exists — aiming_start phase) */}
{wireAiming && !wireInProgress && aimPosition && (
<svg style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', overflow: 'visible', pointerEvents: 'none', zIndex: 40 }}>
<line x1={aimPosition.x - 8000} y1={aimPosition.y} x2={aimPosition.x + 8000} y2={aimPosition.y} stroke="rgba(255,255,255,0.25)" strokeWidth="0.5" strokeDasharray="8,6" />
<line x1={aimPosition.x} y1={aimPosition.y - 8000} x2={aimPosition.x} y2={aimPosition.y + 8000} stroke="rgba(255,255,255,0.25)" strokeWidth="0.5" strokeDasharray="8,6" />
<circle cx={aimPosition.x} cy={aimPosition.y} r="6" fill="none" stroke="rgba(0, 200, 255, 0.8)" strokeWidth="1.5" />
<circle cx={aimPosition.x} cy={aimPosition.y} r="2" fill="rgba(0, 200, 255, 0.9)" />
</svg>
)}
</div>
{/* Wire creation mode banner — visible on both desktop and mobile */}
{wireInProgress && (
{(wireInProgress || wireAiming) && (
<div className="wire-mode-banner">
<span>Tap a pin to connect tap canvas for waypoints</span>
<button onClick={() => cancelWireCreation()}>Cancel</button>
<span>{isTouchDevice
? (wireAiming
? '🎯 Geser ke pin, lepas untuk connect'
: 'Tahan lama untuk aim ke pin')
: 'Tap a pin to connect — tap canvas for waypoints'}</span>
<button onClick={() => {
if (wireInProgress) cancelWireCreation();
wireAimingPhaseRef.current = 'idle';
setWireAiming(false); setAimPosition(null);
if (wireAimingTimerRef.current) { clearTimeout(wireAimingTimerRef.current); wireAimingTimerRef.current = null; }
}}>Cancel</button>
</div>
)}
{/* Wire selected banner — shows Delete + Undo buttons (essential for touch devices) */}
{!wireInProgress && !wireAiming && selectedWireId && (
<div className="wire-selected-banner">
<span>Wire selected</span>
<button className="btn-danger" onClick={() => { removeWire(selectedWireId); }}>Delete</button>
<button onClick={() => { setSelectedWire(null); }}>Deselect</button>
{canUndoWire && <button onClick={undoWire}>Undo</button>}
</div>
)}
</div>
</div>

View File

@ -1,17 +1,24 @@
/**
* WireInProgressRenderer live preview while drawing a wire.
* Shows the fixed waypoints + a dynamic elbow segment to the mouse cursor.
* Includes a full-canvas crosshair at the cursor position (CircuitJS-style)
* to help with alignment and precision on touch devices.
*/
import React from 'react';
import type { WireInProgress } from '../../types/wire';
import { generatePreviewPath, generateOrthogonalPath } from '../../utils/wireUtils';
/** Crosshair arm length in world pixels — large enough to span any visible area */
const CROSSHAIR_EXTENT = 8000;
interface Props {
wireInProgress: WireInProgress;
/** When true, show the full-canvas crosshair guides (aiming mode on touch) */
isAiming?: boolean;
}
export const WireInProgressRenderer: React.FC<Props> = ({ wireInProgress }) => {
export const WireInProgressRenderer: React.FC<Props> = ({ wireInProgress, isAiming = false }) => {
const { startEndpoint, waypoints, color, currentX, currentY } = wireInProgress;
const path = generatePreviewPath(
@ -25,6 +32,26 @@ export const WireInProgressRenderer: React.FC<Props> = ({ wireInProgress }) => {
return (
<g className="wire-in-progress" style={{ pointerEvents: 'none' }}>
{/* ── Crosshair: full-canvas alignment guides (only during aiming) ── */}
{isAiming && (
<>
<line
x1={currentX - CROSSHAIR_EXTENT} y1={currentY}
x2={currentX + CROSSHAIR_EXTENT} y2={currentY}
stroke="rgba(255,255,255,0.25)"
strokeWidth="0.5"
strokeDasharray="8,6"
/>
<line
x1={currentX} y1={currentY - CROSSHAIR_EXTENT}
x2={currentX} y2={currentY + CROSSHAIR_EXTENT}
stroke="rgba(255,255,255,0.25)"
strokeWidth="0.5"
strokeDasharray="8,6"
/>
</>
)}
{/* Dark outline */}
<path d={path} stroke="#1a1a1a" strokeWidth="5" fill="none" />

View File

@ -22,6 +22,10 @@ interface WireLayerProps {
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;
/** Whether the user is in touch-aiming mode (shows crosshair on wire preview) */
isAiming?: boolean;
/** Current canvas zoom level — handles scale inversely to stay constant on screen */
zoom?: number;
}
export const WireLayer: React.FC<WireLayerProps> = ({
@ -30,6 +34,8 @@ export const WireLayer: React.FC<WireLayerProps> = ({
segmentHandles,
onHandleMouseDown,
onHandleTouchStart,
isAiming,
zoom = 1,
}) => {
const wires = useSimulatorStore((s) => s.wires);
const wireInProgress = useSimulatorStore((s) => s.wireInProgress);
@ -63,24 +69,29 @@ export const WireLayer: React.FC<WireLayerProps> = ({
/>
))}
{/* Segment handles for the selected wire */}
{segmentHandles.map((handle) => (
<circle
key={handle.segIndex}
cx={handle.mx}
cy={handle.my}
r={isTouchDevice ? 14 : 7}
fill="white"
stroke="#007acc"
strokeWidth={2}
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)}
/>
))}
{/* Segment handles for the selected wire — scaled inversely to zoom for constant screen size */}
{segmentHandles.map((handle) => {
const baseR = isTouchDevice ? 14 : 7;
const r = baseR / zoom;
const sw = 2 / zoom;
return (
<circle
key={handle.segIndex}
cx={handle.mx}
cy={handle.my}
r={r}
fill="white"
stroke="#007acc"
strokeWidth={sw}
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)}
/>
);
})}
{wireInProgress && (
<WireInProgressRenderer wireInProgress={wireInProgress} />
<WireInProgressRenderer wireInProgress={wireInProgress} isAiming={isAiming} />
)}
</svg>
);

View File

@ -55,7 +55,8 @@ export class ComponentRegistry {
private async _doLoad(): Promise<void> {
try {
const response = await fetch('/components-metadata.json');
const base = import.meta.env.BASE_URL || '/';
const response = await fetch(`${base}components-metadata.json`);
if (!response.ok) {
throw new Error(`Failed to load metadata: ${response.statusText}`);
}

View File

@ -346,7 +346,8 @@ export class Esp32C3Simulator {
// @ts-expect-error kept for future use when more peripherals are emulated
private async _loadRom(): Promise<void> {
try {
const resp = await fetch('/boards/esp32c3-rom.bin');
const base = import.meta.env.BASE_URL || '/';
const resp = await fetch(`${base}boards/esp32c3-rom.bin`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const buf = await resp.arrayBuffer();
this._romData = new Uint8Array(buf);