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 sizemaster
parent
ea82602102
commit
cd85b78b75
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue