import { useSimulatorStore } from '../../store/useSimulatorStore'; import React, { useEffect, useState, useRef, useCallback } from 'react'; import { ComponentPickerModal } from '../ComponentPickerModal'; import { ComponentPropertyDialog } from './ComponentPropertyDialog'; import { DynamicComponent, createComponentFromMetadata } from '../DynamicComponent'; import { ComponentRegistry } from '../../services/ComponentRegistry'; import { PinSelector } from './PinSelector'; import { WireLayer } from './WireLayer'; import type { SegmentHandle } from './WireLayer'; import { BoardOnCanvas } from './BoardOnCanvas'; import { BoardPickerModal } from './BoardPickerModal'; import { PartSimulationRegistry } from '../../simulation/parts'; import { PinOverlay } from './PinOverlay'; import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping'; import { autoWireColor, WIRE_KEY_COLORS } from '../../utils/wireUtils'; import { findWireNearPoint, getRenderedPoints, getRenderedSegments, moveSegment, renderedToWaypoints, renderedPointsToPath, } from '../../utils/wireHitDetection'; import type { ComponentMetadata } from '../../types/component-metadata'; import type { BoardKind } from '../../types/board'; import { BOARD_KIND_LABELS } from '../../types/board'; import { useOscilloscopeStore } from '../../store/useOscilloscopeStore'; import { useEditorStore } from '../../store/useEditorStore'; import './SimulatorCanvas.css'; export const SimulatorCanvas = () => { const { boards, activeBoardId, setBoardPosition, addBoard, components, running, pinManager, initSimulator, updateComponentState, addComponent, removeComponent, updateComponent, serialMonitorOpen, toggleSerialMonitor, } = useSimulatorStore(); // Legacy derived values for components that still use them const boardType = useSimulatorStore((s) => s.boardType); const boardPosition = useSimulatorStore((s) => s.boardPosition); // Wire management from store const startWireCreation = useSimulatorStore((s) => s.startWireCreation); const updateWireInProgress = useSimulatorStore((s) => s.updateWireInProgress); const addWireWaypoint = useSimulatorStore((s) => s.addWireWaypoint); const setWireInProgressColor = useSimulatorStore((s) => s.setWireInProgressColor); const finishWireCreation = useSimulatorStore((s) => s.finishWireCreation); const cancelWireCreation = useSimulatorStore((s) => s.cancelWireCreation); const wireInProgress = useSimulatorStore((s) => s.wireInProgress); const recalculateAllWirePositions = useSimulatorStore((s) => s.recalculateAllWirePositions); const selectedWireId = useSimulatorStore((s) => s.selectedWireId); const setSelectedWire = useSimulatorStore((s) => s.setSelectedWire); const removeWire = useSimulatorStore((s) => s.removeWire); const updateWire = useSimulatorStore((s) => s.updateWire); const wires = useSimulatorStore((s) => s.wires); // Oscilloscope const oscilloscopeOpen = useOscilloscopeStore((s) => s.open); const toggleOscilloscope = useOscilloscopeStore((s) => s.toggleOscilloscope); // ESP32 crash notification const esp32CrashBoardId = useSimulatorStore((s) => s.esp32CrashBoardId); const dismissEsp32Crash = useSimulatorStore((s) => s.dismissEsp32Crash); // Board picker modal const [showBoardPicker, setShowBoardPicker] = useState(false); // Component picker modal const [showComponentPicker, setShowComponentPicker] = useState(false); const [registry] = useState(() => ComponentRegistry.getInstance()); const [registryLoaded, setRegistryLoaded] = useState(registry.isLoaded); // Wait for registry to finish loading before rendering components useEffect(() => { if (!registryLoaded) { registry.loadPromise.then(() => setRegistryLoaded(true)); } }, [registry, registryLoaded]); // Component selection const [selectedComponentId, setSelectedComponentId] = useState(null); const [showPinSelector, setShowPinSelector] = useState(false); const [pinSelectorPos, setPinSelectorPos] = useState({ x: 0, y: 0 }); // Component property dialog const [showPropertyDialog, setShowPropertyDialog] = useState(false); const [propertyDialogComponentId, setPropertyDialogComponentId] = useState(null); const [propertyDialogPosition, setPropertyDialogPosition] = useState({ x: 0, y: 0 }); // Click vs drag detection const [clickStartTime, setClickStartTime] = useState(0); const [clickStartPos, setClickStartPos] = useState({ x: 0, y: 0 }); // Component dragging state const [draggedComponentId, setDraggedComponentId] = useState(null); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); // Canvas ref for coordinate calculations const canvasRef = useRef(null); // Pan & zoom state const [pan, setPan] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); // Use refs during active pan to avoid setState lag const isPanningRef = useRef(false); const panStartRef = useRef({ mouseX: 0, mouseY: 0, panX: 0, panY: 0 }); const panRef = useRef({ x: 0, y: 0 }); const zoomRef = useRef(1); // Refs that mirror state/props for use inside touch event closures // (touch listeners are added imperatively and can't access current React state) const runningRef = useRef(running); runningRef.current = running; const componentsRef = useRef(components); componentsRef.current = components; const boardPositionRef = useRef(boardPosition); boardPositionRef.current = boardPosition; // Wire interaction state (canvas-level hit detection — bypasses SVG pointer-events issues) const [hoveredWireId, setHoveredWireId] = useState(null); const [segmentDragPreview, setSegmentDragPreview] = useState<{ wireId: string; overridePath: string; } | null>(null); const segmentDragRef = useRef<{ wireId: string; segIndex: number; axis: 'horizontal' | 'vertical'; renderedPts: { x: number; y: number }[]; isDragging: boolean; } | null>(null); /** Set to true during mouseup if a segment drag committed, so onClick can skip selection. */ const segmentDragJustCommittedRef = useRef(false); const wiresRef = useRef(wires); wiresRef.current = wires; // Compute midpoint handles for the selected wire's segments const segmentHandles = React.useMemo(() => { if (!selectedWireId) return []; const wire = wires.find((w) => w.id === selectedWireId); if (!wire) return []; return getRenderedSegments(wire).map((seg, i) => ({ segIndex: i, axis: seg.axis, mx: (seg.x1 + seg.x2) / 2, my: (seg.y1 + seg.y2) / 2, })); }, [selectedWireId, wires]); // Touch-specific state refs (for single-finger drag and pinch-to-zoom) const touchDraggedComponentIdRef = useRef(null); const touchDragOffsetRef = useRef({ x: 0, y: 0 }); const touchClickStartTimeRef = useRef(0); const touchClickStartPosRef = useRef({ x: 0, y: 0 }); const pinchStartDistRef = useRef(0); const pinchStartZoomRef = useRef(1); const pinchStartMidRef = useRef({ x: 0, y: 0 }); const pinchStartPanRef = useRef({ x: 0, y: 0 }); // Convert viewport coords to world (canvas) coords const toWorld = useCallback((screenX: number, screenY: number) => { const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return { x: screenX, y: screenY }; return { x: (screenX - rect.left - panRef.current.x) / zoomRef.current, y: (screenY - rect.top - panRef.current.y) / zoomRef.current, }; }, []); // Initialize simulator on mount useEffect(() => { initSimulator(); }, [initSimulator]); // Auto-start/stop Pi bridges when simulation state changes const startBoard = useSimulatorStore((s) => s.startBoard); const stopBoard = useSimulatorStore((s) => s.stopBoard); useEffect(() => { const remoteBoards = boards.filter( (b) => b.boardKind === 'raspberry-pi-3' || b.boardKind === 'esp32' || b.boardKind === 'esp32-s3' || b.boardKind === 'esp32-c3' ); remoteBoards.forEach((b) => { if (running && !b.running) startBoard(b.id); else if (!running && b.running) stopBoard(b.id); }); }, [running, boards, startBoard, stopBoard]); // Attach wheel listener as non-passive so preventDefault() works useEffect(() => { const el = canvasRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); const rect = el.getBoundingClientRect(); const factor = e.deltaY < 0 ? 1.1 : 0.9; const newZoom = Math.min(5, Math.max(0.1, zoomRef.current * factor)); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const worldX = (mx - panRef.current.x) / zoomRef.current; const worldY = (my - panRef.current.y) / zoomRef.current; const newPan = { x: mx - worldX * newZoom, y: my - worldY * newZoom }; zoomRef.current = newZoom; panRef.current = newPan; setZoom(newZoom); setPan(newPan); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, []); // Attach touch listeners as non-passive so preventDefault() works, enabling // single-finger pan, single-finger component drag, and two-finger pinch-to-zoom. useEffect(() => { const el = canvasRef.current; if (!el) return; const onTouchStart = (e: TouchEvent) => { e.preventDefault(); // Prevent browser scroll / mouse-event synthesis pinchStartDistRef.current = 0; // Reset pinch state on each new gesture if (e.touches.length === 2) { // ── Two-finger pinch: cancel any active drag/pan and prepare zoom ── isPanningRef.current = false; touchDraggedComponentIdRef.current = null; const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; pinchStartDistRef.current = Math.sqrt(dx * dx + dy * dy); pinchStartZoomRef.current = zoomRef.current; pinchStartPanRef.current = { ...panRef.current }; const rect = el.getBoundingClientRect(); pinchStartMidRef.current = { x: (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left, y: (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top, }; return; } if (e.touches.length !== 1) return; const touch = e.touches[0]; touchClickStartTimeRef.current = Date.now(); touchClickStartPosRef.current = { x: touch.clientX, y: touch.clientY }; // Identify what element was touched const target = document.elementFromPoint(touch.clientX, touch.clientY); const componentWrapper = target?.closest('[data-component-id]') as HTMLElement | null; const boardOverlay = target?.closest('[data-board-overlay]') as HTMLElement | null; if (componentWrapper && !runningRef.current) { // ── Single finger on a component: start drag ── const componentId = componentWrapper.getAttribute('data-component-id'); if (componentId) { const component = componentsRef.current.find((c) => c.id === componentId); if (component) { const world = toWorld(touch.clientX, touch.clientY); touchDraggedComponentIdRef.current = componentId; touchDragOffsetRef.current = { x: world.x - component.x, y: world.y - component.y, }; setSelectedComponentId(componentId); } } } else if (boardOverlay && !runningRef.current) { // ── Single finger on the board overlay: start board drag ── const board = boardPositionRef.current; const world = toWorld(touch.clientX, touch.clientY); touchDraggedComponentIdRef.current = '__board__'; touchDragOffsetRef.current = { x: world.x - board.x, y: world.y - board.y, }; } else { // ── Single finger on empty canvas: start pan ── isPanningRef.current = true; panStartRef.current = { mouseX: touch.clientX, mouseY: touch.clientY, panX: panRef.current.x, panY: panRef.current.y, }; } }; const onTouchMove = (e: TouchEvent) => { e.preventDefault(); if (e.touches.length === 2 && pinchStartDistRef.current > 0) { // ── Two-finger pinch: update zoom ── const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; const dist = Math.sqrt(dx * dx + dy * dy); const scale = dist / pinchStartDistRef.current; const newZoom = Math.min(5, Math.max(0.1, pinchStartZoomRef.current * scale)); const mid = pinchStartMidRef.current; const startPan = pinchStartPanRef.current; const startZoom = pinchStartZoomRef.current; const worldX = (mid.x - startPan.x) / startZoom; const worldY = (mid.y - startPan.y) / startZoom; const newPan = { x: mid.x - worldX * newZoom, y: mid.y - worldY * newZoom, }; zoomRef.current = newZoom; panRef.current = newPan; const worldEl = el.querySelector('.canvas-world') as HTMLElement | null; if (worldEl) { worldEl.style.transform = `translate(${newPan.x}px, ${newPan.y}px) scale(${newZoom})`; } return; } if (e.touches.length !== 1) return; const touch = e.touches[0]; if (isPanningRef.current) { // ── Single finger pan ── const dx = touch.clientX - panStartRef.current.mouseX; const dy = touch.clientY - panStartRef.current.mouseY; const newPan = { x: panStartRef.current.panX + dx, y: panStartRef.current.panY + dy, }; panRef.current = newPan; const worldEl = el.querySelector('.canvas-world') as HTMLElement | null; if (worldEl) { worldEl.style.transform = `translate(${newPan.x}px, ${newPan.y}px) scale(${zoomRef.current})`; } } else if (touchDraggedComponentIdRef.current) { // ── Single finger component/board drag ── const world = toWorld(touch.clientX, touch.clientY); const touchId = touchDraggedComponentIdRef.current; if (touchId && touchId.startsWith('__board__:')) { const boardId = touchId.slice('__board__:'.length); setBoardPosition({ x: world.x - touchDragOffsetRef.current.x, y: world.y - touchDragOffsetRef.current.y, }, boardId); } else if (touchId === '__board__') { setBoardPosition({ x: world.x - touchDragOffsetRef.current.x, y: world.y - touchDragOffsetRef.current.y, }); } else { updateComponent(touchDraggedComponentIdRef.current!, { x: world.x - touchDragOffsetRef.current.x, y: world.y - touchDragOffsetRef.current.y, } as any); } } }; const onTouchEnd = (e: TouchEvent) => { e.preventDefault(); // ── Finish pinch zoom: commit values to React state ── if (pinchStartDistRef.current > 0 && e.touches.length < 2) { setZoom(zoomRef.current); setPan({ ...panRef.current }); pinchStartDistRef.current = 0; } if (e.touches.length > 0) return; // Still fingers on screen // ── Finish panning ── if (isPanningRef.current) { isPanningRef.current = false; setPan({ ...panRef.current }); } const changed = e.changedTouches[0]; // ── Finish component/board drag ── if (touchDraggedComponentIdRef.current) { const elapsed = Date.now() - touchClickStartTimeRef.current; const dx = changed ? changed.clientX - touchClickStartPosRef.current.x : 0; const dy = changed ? changed.clientY - touchClickStartPosRef.current.y : 0; const dist = Math.sqrt(dx * dx + dy * dy); // Short tap with minimal movement → open property dialog if (dist < 5 && elapsed < 300 && touchDraggedComponentIdRef.current !== '__board__') { const component = componentsRef.current.find( (c) => c.id === touchDraggedComponentIdRef.current ); if (component) { setPropertyDialogComponentId(touchDraggedComponentIdRef.current); setPropertyDialogPosition({ x: component.x, y: component.y }); setShowPropertyDialog(true); } } recalculateAllWirePositions(); touchDraggedComponentIdRef.current = null; return; } // ── Short tap on empty canvas: deselect ── if (changed) { const elapsed = Date.now() - touchClickStartTimeRef.current; const dx = changed.clientX - touchClickStartPosRef.current.x; const dy = changed.clientY - touchClickStartPosRef.current.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 5 && elapsed < 300) { setSelectedComponentId(null); } } }; el.addEventListener('touchstart', onTouchStart, { passive: false }); el.addEventListener('touchmove', onTouchMove, { passive: false }); el.addEventListener('touchend', onTouchEnd, { passive: false }); return () => { el.removeEventListener('touchstart', onTouchStart); el.removeEventListener('touchmove', onTouchMove); el.removeEventListener('touchend', onTouchEnd); }; }, [toWorld, setBoardPosition, updateComponent, recalculateAllWirePositions]); // Recalculate wire positions after web components initialize their pinInfo useEffect(() => { const timer = setTimeout(() => { recalculateAllWirePositions(); }, 500); return () => clearTimeout(timer); }, [recalculateAllWirePositions]); // Connect components to pin manager useEffect(() => { const unsubscribers: (() => void)[] = []; // Helper to add subscription const subscribeComponentToPin = (component: any, pin: number, componentPinName?: string) => { const unsubscribe = pinManager.onPinChange( pin, (_pin, state) => { // 1. Update React state for standard properties updateComponentState(component.id, state); // 2. Delegate to PartSimulationRegistry for custom visual updates const logic = PartSimulationRegistry.get(component.metadataId); if (logic && logic.onPinStateChange) { const el = document.getElementById(component.id); if (el) { logic.onPinStateChange(componentPinName || 'A', state, el); } } console.log(`Component ${component.id} on pin ${pin}: ${state ? 'HIGH' : 'LOW'}`); } ); unsubscribers.push(unsubscribe); }; components.forEach((component) => { // 1. Subscribe by explicit pin property if (component.properties.pin !== undefined) { subscribeComponentToPin(component, component.properties.pin as number, 'A'); } else { // 2. Subscribe by finding wires connected to arduino const connectedWires = useSimulatorStore.getState().wires.filter( w => w.start.componentId === component.id || w.end.componentId === component.id ); connectedWires.forEach(wire => { const isStartSelf = wire.start.componentId === component.id; const selfEndpoint = isStartSelf ? wire.start : wire.end; const otherEndpoint = isStartSelf ? wire.end : wire.start; if (isBoardComponent(otherEndpoint.componentId)) { // Use the board's actual boardKind (not just its instance ID) so that // a board whose ID is 'arduino-uno' but whose kind is 'esp32' gets the // correct GPIO mapping ('GPIO4' → 4, not null). const boardInstance = boards.find(b => b.id === otherEndpoint.componentId); const lookupKey = boardInstance ? boardInstance.boardKind : otherEndpoint.componentId; const pin = boardPinToNumber(lookupKey, otherEndpoint.pinName); console.log( `[WirePin] component=${component.id} board=${otherEndpoint.componentId}` + ` kind=${lookupKey} pinName=${otherEndpoint.pinName} → gpioPin=${pin}` ); if (pin !== null) { subscribeComponentToPin(component, pin, selfEndpoint.pinName); } else { console.warn(`[WirePin] Could not resolve pin "${otherEndpoint.pinName}" on ${lookupKey}`); } } }); } }); return () => { unsubscribers.forEach(unsub => unsub()); }; }, [components, pinManager, updateComponentState]); // Handle keyboard delete useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.key === 'Delete' || e.key === 'Backspace') && selectedComponentId) { removeComponent(selectedComponentId); setSelectedComponentId(null); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedComponentId, removeComponent]); // Handle component selection from modal const handleSelectComponent = (metadata: ComponentMetadata) => { // Calculate grid position to avoid overlapping // Use existing components count to determine position const componentsCount = components.length; const gridSize = 250; // Space between components const cols = 3; // Components per row const col = componentsCount % cols; const row = Math.floor(componentsCount / cols); const x = 400 + (col * gridSize); const y = 100 + (row * gridSize); const component = createComponentFromMetadata(metadata, x, y); addComponent(component as any); setShowComponentPicker(false); }; // Component selection (double click to open pin selector) const handleComponentDoubleClick = (componentId: string, event: React.MouseEvent) => { event.stopPropagation(); setSelectedComponentId(componentId); setPinSelectorPos({ x: event.clientX, y: event.clientY }); setShowPinSelector(true); }; // Pin assignment const handlePinSelect = (componentId: string, pin: number) => { updateComponent(componentId, { properties: { ...components.find((c) => c.id === componentId)?.properties, pin, }, } as any); }; // Component rotation const handleRotateComponent = (componentId: string) => { const component = components.find((c) => c.id === componentId); if (!component) return; const currentRotation = (component.properties.rotation as number) || 0; updateComponent(componentId, { properties: { ...component.properties, rotation: (currentRotation + 90) % 360, }, } as any); }; // Component dragging handlers const handleComponentMouseDown = (componentId: string, e: React.MouseEvent) => { if (showPinSelector || showPropertyDialog) return; e.stopPropagation(); const component = components.find((c) => c.id === componentId); if (!component) return; setClickStartTime(Date.now()); setClickStartPos({ x: e.clientX, y: e.clientY }); const world = toWorld(e.clientX, e.clientY); setDraggedComponentId(componentId); setDragOffset({ x: world.x - component.x, y: world.y - component.y, }); setSelectedComponentId(componentId); }; const handleCanvasMouseMove = (e: React.MouseEvent) => { // Handle active panning (ref-based, no setState lag) if (isPanningRef.current) { const dx = e.clientX - panStartRef.current.mouseX; const dy = e.clientY - panStartRef.current.mouseY; const newPan = { x: panStartRef.current.panX + dx, y: panStartRef.current.panY + dy, }; panRef.current = newPan; // Update the transform directly for zero-lag panning const world = canvasRef.current?.querySelector('.canvas-world') as HTMLElement | null; if (world) { world.style.transform = `translate(${newPan.x}px, ${newPan.y}px) scale(${zoomRef.current})`; } return; } // Handle component/board dragging if (draggedComponentId) { const world = toWorld(e.clientX, e.clientY); if (draggedComponentId.startsWith('__board__:')) { const boardId = draggedComponentId.slice('__board__:'.length); setBoardPosition({ x: world.x - dragOffset.x, y: world.y - dragOffset.y }, boardId); } else if (draggedComponentId === '__board__') { // legacy fallback setBoardPosition({ x: world.x - dragOffset.x, y: world.y - dragOffset.y }); } else { updateComponent(draggedComponentId, { x: world.x - dragOffset.x, y: world.y - dragOffset.y, } as any); } } // Handle wire creation preview if (wireInProgress) { const world = toWorld(e.clientX, e.clientY); updateWireInProgress(world.x, world.y); return; } // Handle segment handle dragging if (segmentDragRef.current) { const world = toWorld(e.clientX, e.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 hover detection (when not dragging anything) if (!draggedComponentId) { const world = toWorld(e.clientX, e.clientY); const threshold = 8 / zoomRef.current; const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold); setHoveredWireId(wire ? wire.id : null); } }; const handleCanvasMouseUp = (e: React.MouseEvent) => { // Finish panning — commit ref value to state so React knows the final pan if (isPanningRef.current) { isPanningRef.current = false; setPan({ ...panRef.current }); return; } // Commit segment handle drag if (segmentDragRef.current) { const sd = segmentDragRef.current; if (sd.isDragging) { segmentDragJustCommittedRef.current = true; const world = toWorld(e.clientX, e.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; } if (draggedComponentId) { const timeDiff = Date.now() - clickStartTime; const posDiff = Math.sqrt( Math.pow(e.clientX - clickStartPos.x, 2) + Math.pow(e.clientY - clickStartPos.y, 2) ); if (posDiff < 5 && timeDiff < 300 && draggedComponentId !== '__board__') { const component = components.find((c) => c.id === draggedComponentId); if (component) { setPropertyDialogComponentId(draggedComponentId); setPropertyDialogPosition({ x: component.x, y: component.y }); setShowPropertyDialog(true); } } recalculateAllWirePositions(); setDraggedComponentId(null); } }; // Start panning on middle-click or right-click const handleCanvasMouseDown = (e: React.MouseEvent) => { if (e.button === 1 || e.button === 2) { e.preventDefault(); isPanningRef.current = true; panStartRef.current = { mouseX: e.clientX, mouseY: e.clientY, panX: panRef.current.x, panY: panRef.current.y, }; } }; // Handle mousedown on a segment handle circle (called from WireLayer) const handleHandleMouseDown = useCallback( (e: React.MouseEvent, segIndex: number) => { e.stopPropagation(); e.preventDefault(); 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(); const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; const factor = e.deltaY < 0 ? 1.1 : 0.9; const newZoom = Math.min(5, Math.max(0.1, zoomRef.current * factor)); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; // Keep the world point under the cursor fixed const worldX = (mx - panRef.current.x) / zoomRef.current; const worldY = (my - panRef.current.y) / zoomRef.current; const newPan = { x: mx - worldX * newZoom, y: my - worldY * newZoom, }; zoomRef.current = newZoom; panRef.current = newPan; setZoom(newZoom); setPan(newPan); }; const handleResetView = () => { zoomRef.current = 1; panRef.current = { x: 0, y: 0 }; setZoom(1); setPan({ x: 0, y: 0 }); }; // Wire creation via pin clicks const handlePinClick = (componentId: string, pinName: string, x: number, y: number) => { // Close property dialog when starting wire creation if (showPropertyDialog) { setShowPropertyDialog(false); } if (wireInProgress) { // Finish wire: connect to this pin finishWireCreation({ componentId, pinName, x, y }); } else { // Start wire: auto-detect color from pin name startWireCreation({ componentId, pinName, x, y }, autoWireColor(pinName)); } }; // Keyboard handlers for wires useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Escape → cancel in-progress wire if (e.key === 'Escape' && wireInProgress) { cancelWireCreation(); return; } // Delete / Backspace → remove selected wire if ((e.key === 'Delete' || e.key === 'Backspace') && selectedWireId) { removeWire(selectedWireId); return; } // Color shortcuts (0-9, c, l, m, p, y) — Wokwi style const key = e.key.toLowerCase(); if (key in WIRE_KEY_COLORS) { if (wireInProgress) { setWireInProgressColor(WIRE_KEY_COLORS[key]); } else if (selectedWireId) { updateWire(selectedWireId, { color: WIRE_KEY_COLORS[key] }); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [wireInProgress, cancelWireCreation, selectedWireId, removeWire, setWireInProgressColor, updateWire]); // Recalculate wire positions when components change (e.g., when loading an example) useEffect(() => { // Wait for components to render and pinInfo to be available // Use multiple retries to ensure pinInfo is ready const timers: ReturnType[] = []; // Try at 100ms, 300ms, and 500ms to ensure all components have rendered timers.push(setTimeout(() => recalculateAllWirePositions(), 100)); timers.push(setTimeout(() => recalculateAllWirePositions(), 300)); timers.push(setTimeout(() => recalculateAllWirePositions(), 500)); return () => timers.forEach(t => clearTimeout(t)); }, [components, recalculateAllWirePositions]); // Auto-pan to keep the board visible after a project import/load. // We track the previous component count and only re-center when the count // jumps (indicating the user loaded a new circuit, not just added one part). const prevComponentCountRef = useRef(-1); useEffect(() => { const prev = prevComponentCountRef.current; const curr = components.length; prevComponentCountRef.current = curr; // Only re-center when the component list transitions from empty/different // project to a populated one (i.e., a load/import event). const isLoad = curr > 0 && (prev <= 0 || Math.abs(curr - prev) > 2); if (!isLoad) return; const timer = setTimeout(() => { const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const currentZoom = zoomRef.current; const newPan = { x: rect.width / 4 - boardPosition.x * currentZoom, y: rect.height / 4 - boardPosition.y * currentZoom, }; panRef.current = newPan; setPan(newPan); }, 150); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [components.length]); // Render component using dynamic renderer const renderComponent = (component: any) => { const metadata = registry.getById(component.metadataId); if (!metadata) { console.warn(`Metadata not found for component: ${component.metadataId}`); return null; } const isSelected = selectedComponentId === component.id; // Always show pins for better UX when creating wires const showPinsForComponent = true; return ( { // Only handle UI events when simulation is NOT running if (!running) { handleComponentMouseDown(component.id, e); } }} onDoubleClick={(e) => { // Only handle UI events when simulation is NOT running if (!running) { handleComponentDoubleClick(component.id, e); } }} /> {/* Pin overlay for wire creation - hide when running */} {!running && ( )} ); }; return (
{/* ESP32 crash notification */} {esp32CrashBoardId && (
ESP32 crash detected on board {esp32CrashBoardId} — cache error (IDF incompatibility)
)} {/* Main Canvas */}
{/* Status LED */} {/* Active board selector (multi-board) */} {/* Serial Monitor toggle */} {/* Oscilloscope toggle */}
{/* Zoom controls */}
{/* Component count */} {components.length} {/* Add Component */} {/* Add Board */}
{ isPanningRef.current = false; setPan({ ...panRef.current }); setDraggedComponentId(null); }} onContextMenu={(e) => { e.preventDefault(); if (wireInProgress) cancelWireCreation(); }} onClick={(e) => { if (wireInProgress) { const world = toWorld(e.clientX, e.clientY); addWireWaypoint(world.x, world.y); return; } // If a segment handle drag just finished, don't also select if (segmentDragJustCommittedRef.current) { segmentDragJustCommittedRef.current = false; return; } // Wire selection via canvas-level hit detection const world = toWorld(e.clientX, e.clientY); const threshold = 8 / zoomRef.current; const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold); if (wire) { setSelectedWire(selectedWireId === wire.id ? null : wire.id); } else { setSelectedWire(null); setSelectedComponentId(null); } }} onDoubleClick={(e) => { if (wireInProgress) return; const world = toWorld(e.clientX, e.clientY); const threshold = 8 / zoomRef.current; const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold); if (wire) { removeWire(wire.id); } }} style={{ cursor: isPanningRef.current ? 'grabbing' : wireInProgress ? 'crosshair' : hoveredWireId ? 'pointer' : 'default', }} > {/* Infinite world — pan+zoom applied here */}
{/* Wire Layer - Renders below all components */} {/* All boards on canvas */} {boards.map((board) => ( c.id === 'led-builtin')?.properties.state)} onMouseDown={(e) => { const world = toWorld(e.clientX, e.clientY); setDraggedComponentId(`__board__:${board.id}`); setDragOffset({ x: world.x - board.x, y: world.y - board.y }); }} onPinClick={handlePinClick} /> ))} {/* Components using wokwi-elements */}
{registryLoaded && components.map(renderComponent)}
{/* Pin Selector Modal */} {showPinSelector && selectedComponentId && ( c.id === selectedComponentId)?.metadataId || 'unknown' } currentPin={ components.find((c) => c.id === selectedComponentId)?.properties.pin as number | undefined } onPinSelect={handlePinSelect} onClose={() => setShowPinSelector(false)} position={pinSelectorPos} /> )} {/* Component Property Dialog */} {showPropertyDialog && propertyDialogComponentId && (() => { const component = components.find((c) => c.id === propertyDialogComponentId); const metadata = component ? registry.getById(component.metadataId) : null; if (!component || !metadata) return null; const element = document.getElementById(propertyDialogComponentId); const pinInfo = element ? (element as any).pinInfo : []; return ( setShowPropertyDialog(false)} onRotate={handleRotateComponent} onDelete={(id) => { removeComponent(id); setShowPropertyDialog(false); }} /> ); })()} {/* Component Picker Modal */} setShowComponentPicker(false)} onSelectComponent={handleSelectComponent} /> {/* Board Picker Modal */} setShowBoardPicker(false)} onSelectBoard={(kind: BoardKind) => { const sameKind = boards.filter((b) => b.boardKind === kind); const newBoardId = sameKind.length === 0 ? kind : `${kind}-${sameKind.length + 1}`; const x = boardPosition.x + boards.length * 60 + 420; const y = boardPosition.y + boards.length * 30; addBoard(kind, x, y); useEditorStore.getState().createFileGroup(`group-${newBoardId}`); }} />
); };