import { useSimulatorStore, getEsp32Bridge } from '../../store/useSimulatorStore'; import { useElectricalStore } from '../../store/useElectricalStore'; import { openDeviceGateway } from '../../lib/openDeviceGateway'; import React, { useEffect, useState, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { Undo2, Redo2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { ESP32_ADC_PIN_MAP } from '../velxio-components/Esp32Element'; import { ComponentPickerModal } from '../ComponentPickerModal'; import { ComponentPropertyDialog } from './ComponentPropertyDialog'; import { CustomChipDialog } from '../customChips/CustomChipDialog'; import { SensorControlPanel } from './SensorControlPanel'; import { SENSOR_CONTROLS } from '../../simulation/sensorControlConfig'; import { DynamicComponent, createComponentFromMetadata } from '../DynamicComponent'; import { InstrumentComponent } from '../components-instruments/InstrumentComponent'; import { ComponentRegistry } from '../../services/ComponentRegistry'; import { getTabSessionId } from '../../simulation/Esp32Bridge'; import { CameraToggle } from './CameraToggle'; import { WireLayer } from './WireLayer'; import type { SegmentHandle, WaypointHandle, AlignmentGuide } from './WireLayer'; import { ElectricalOverlay } from '../analog-ui/ElectricalOverlay'; import { BoardOnCanvas } from './BoardOnCanvas'; import { CanvasMinimap } from './CanvasMinimap'; import { PartSimulationRegistry } from '../../simulation/parts'; import { PROPERTY_CHANGE_EVENT, type PropertyChangeDetail } from '../../simulation/parts/partUtils'; import { mountDigitalGateEngine } from '../../simulation/digital/digitalGateController'; import { isSpiceMapped } from '../../simulation/spice/componentToSpice'; import { PinOverlay } from './PinOverlay'; import { SeatedPinMarkers } from './SeatedPinMarkers'; import { calculatePinPosition } from '../../utils/pinPositionCalculator'; import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping'; import { isBreadboard } from '../../utils/breadboardNets'; import { autoWireColor, railWireColor, WIRE_JUMPER_PALETTE, WIRE_KEY_COLORS, expandOrthogonalPoints, } from '../../utils/wireUtils'; import { findWireAtHole, resolveFreeHole } from '../../utils/breadboardOccupancy'; import { isAutoVerticalPart, isOverBreadboard, seatOnDrop, snapPositionToBreadboard, } from '../../utils/breadboardSnap'; import { findWireNearPoint, findSegmentNearPoint, getRenderedPoints, getRenderedSegments, moveSegment, renderedToWaypoints, renderedPointsToPath, simplifyOrthogonalPath, insertWaypointAtSegment, collectAlignmentTargets, addOwnWireAlignmentTargets, snapToNearest, } from '../../utils/wireHitDetection'; import { useIsCoarsePointer } from '../../utils/useTouchDevice'; import type { ComponentMetadata } from '../../types/component-metadata'; import type { BoardKind } from '../../types/board'; import { BOARD_KIND_FQBN, boardDisplayName } from '../../types/board'; import { boardGateDecision, proBoardFeatureName, triggerProUpgradePrompt } from '../../lib/proBoardGate'; import { FlashModal } from './FlashModal'; import { isTauri as isTauriRuntimeFn } from '../../desktop/tauriBridge'; import { isEsp32Family } from '../../types/boardOptions'; import { BoardOptionsModal } from './BoardOptionsModal'; import { useOscilloscopeStore } from '../../store/useOscilloscopeStore'; import { trackSelectBoard, trackAddComponent, trackCreateWire, trackToggleSerialMonitor, } from '../../utils/analytics'; import { SelectionActionBar } from './SelectionActionBar'; import { WireModeBanner } from './WireModeBanner'; import { PinPickerDialog } from './PinPickerDialog'; import { useButtonKeyBindings } from '../../hooks/useButtonKeyBindings'; import './SimulatorCanvas.css'; /** World-units of tolerance for alignment snap (scales with zoom). */ const ALIGN_SNAP_PX = 6; /** * Large-bodied display parts whose face should occlude wires when the part is * seated on a breadboard — the flat canvas has no depth, so bridge wires * routed to holes UNDER the body would otherwise paint over the readout. * Matches both the metadata ids (`7segment`, `ssd1306`, …) and the `wokwi-`/ * `velxio-` element-name variants. Thin two-pin parts are deliberately absent. */ const DISPLAY_BODY_METADATA_RE = /(7segment|led-matrix|max7219|neopixel-matrix|ssd1306|oled|ili9341|lcd1602|lcd2004|led-ring)/i; /** Long-press duration for touch context menu (ms). */ const LONG_PRESS_MS = 500; /** Max movement during long-press before it cancels (px). */ const LONG_PRESS_MOVE_TOLERANCE = 8; /** * Distance (px, screen) a touch must drift before a passthrough-to- * wokwi-element touch is promoted to a component drag. Set just above * normal tap jitter so accidental drags from a finger press are rare. */ const DRAG_PROMOTE_THRESHOLD_PX = 8; /** Check if a board kind is an ESP32-family board. */ function isEsp32Kind(kind: BoardKind): boolean { return ( kind.startsWith('esp32') || kind === 'xiao-esp32-s3' || kind === 'xiao-esp32-c3' || kind === 'arduino-nano-esp32' || kind === 'aitewinrobot-esp32c3-supermini' || kind === 'esp32-cam' || kind === 'wemos-lolin32-lite' || kind === 'esp32-devkit-c-v4' ); } interface SimulatorCanvasProps { /** * Optional DOM element to portal the canvas header (board selector, * Serial/Scope toggles, zoom controls, Add component button) into. When * provided, the header renders into the slot instead of in place — used by * EditorPage to merge it with the editor toolbar into a single full-width * top bar that doesn't reflow when the editor/canvas splitter moves. */ headerSlot?: HTMLElement | null; } export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { const { t } = useTranslation(); const isTouchDevice = useIsCoarsePointer(); // Mirror to a ref so the long-lived touch handler effect (deps deliberately // narrow to avoid rebinding listeners on every render) can read the latest // value without listing it as a dep. const isTouchDeviceRef = useRef(isTouchDevice); isTouchDeviceRef.current = isTouchDevice; const { boards, activeBoardId, setBoardPosition, addBoard, components, running, sensorResetNonce, pinManager, initSimulator, updateComponentState, removeBoard, updateBoard, updateComponent, serialMonitorOpen, toggleSerialMonitor, } = useSimulatorStore(); // `addComponent` / `removeComponent` / `removeWire` are no longer used here — // every user-initiated mutation routes through the record* actions below // so it can be undone. Raw mutators are still available for transient // operations (e.g. drag preview frames) but those don't live in this file. // Active board (for WiFi/BLE status display) const activeBoard = boards.find((b) => b.id === activeBoardId) ?? null; // Keyboard → pushbutton bindings (component `key` property). useButtonKeyBindings(components); // 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 updateWire = useSimulatorStore((s) => s.updateWire); const wires = useSimulatorStore((s) => s.wires); // Recorded canvas actions — these wrap the raw mutators above with an // undoable CanvasCommand. Use these at the *commit* point of a user // interaction (drag-end, click finish, picker confirm); use the raw // mutators for transient state during the interaction (drag preview). const recordAddComponent = useSimulatorStore((s) => s.recordAddComponent); const recordRemoveComponent = useSimulatorStore((s) => s.recordRemoveComponent); const recordMove = useSimulatorStore((s) => s.recordMove); const recordRotate = useSimulatorStore((s) => s.recordRotate); const recordSetProperty = useSimulatorStore((s) => s.recordSetProperty); const recordRemoveWire = useSimulatorStore((s) => s.recordRemoveWire); const recordUpdateWire = useSimulatorStore((s) => s.recordUpdateWire); // Subscribe to history shape so the undo/redo buttons reactively // enable/disable and their tooltips reflect the next command. const history = useSimulatorStore((s) => s.history); const historyIndex = useSimulatorStore((s) => s.historyIndex); const undo = useSimulatorStore((s) => s.undo); const redo = useSimulatorStore((s) => s.redo); // 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); // 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); // Hover tracking — drives the conditional pin overlay so the canvas isn't // permanently covered in pin chips. Pins show for the hovered/selected // component or board, plus all elements while a wire is in progress. const [hoveredComponentId, setHoveredComponentId] = useState(null); const [hoveredBoardId, setHoveredBoardId] = useState(null); // Touch-friendly pin picker — shown when the user taps a component or board // body (not a tiny pin overlay) and we want to let them pick a pin from a // list. `kind` distinguishes board vs component so we can pull the right // metadata for the dialog title. const [pinPicker, setPinPicker] = useState< { kind: 'component' | 'board'; targetId: string } | null >(null); // Component property dialog const [showPropertyDialog, setShowPropertyDialog] = useState(false); const [propertyDialogComponentId, setPropertyDialogComponentId] = useState(null); /** When non-null, the Custom Chip designer dialog is open for this component. */ const [customChipComponentId, setCustomChipComponentId] = useState(null); const [propertyDialogPosition, setPropertyDialogPosition] = useState({ x: 0, y: 0 }); // Sensor control panel (shown instead of property dialog for sensor components during simulation) const [sensorControlComponentId, setSensorControlComponentId] = useState(null); const [sensorControlMetadataId, setSensorControlMetadataId] = useState(null); // Board built-in LED states (pin 13 for AVR, GPIO25 for RP2040, etc.) // Tracks directly from pinManager — independent of any led-builtin component. const [boardLedStates, setBoardLedStates] = useState>({}); // Board context menu (right-click) const [boardContextMenu, setBoardContextMenu] = useState<{ boardId: string; x: number; y: number; } | null>(null); // Right-click context menu for a wire (color swatches + delete). const [wireContextMenu, setWireContextMenu] = useState<{ wireId: string; x: number; y: number; } | null>(null); // Board removal confirmation dialog const [boardToRemove, setBoardToRemove] = useState(null); // Board Options modal — id of the board whose options are being edited. const [boardOptionsModalFor, setBoardOptionsModalFor] = useState(null); // Hardware-flash modal: set to a board id when the user picks // "Flash to real board" from the board context menu. The FlashModal // owns its own port-picker / progress UI; we just gate the mount. const [flashModalFor, setFlashModalFor] = useState(null); // Cached Tauri-runtime probe — used to gate the "Flash to real // board" menu item in web builds. Hooks-stable across re-renders. const isTauriRuntime = useRef(isTauriRuntimeFn()).current; // 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); // Captures (x, y) of the dragged component at mousedown so a drag-end // can record the diff as a single undoable Move. Boards are intentionally // skipped — board moves don't go through component history. const dragStartPosRef = useRef<{ x: number; y: number } | null>(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); // Board-less SPICE circuits (analog / digital examples with no MCU on // the canvas) have no concept of a board to "start", so `running` is // always false. But the simulation IS effectively live the moment the // engine has solved at least once — switches and buttons should toggle // their own state on click instead of opening the property dialog. // We treat board-less + un-paused as "interaction-running" so the same // gating logic that suppresses the dialog for MCU-running mode also // covers board-less circuits — BUT only when SPICE has actually // engaged (submittedNetlist != ''). Without that extra check, deleting // the only board on a normal Arduino+LED circuit dropped boards to 0 // and silently flipped every remaining component to "running" mode, // which suppresses the property dialog and makes them appear // unresponsive to clicks (issue #211). const electricalPaused = useElectricalStore((s) => s.paused); const electricalEngaged = useElectricalStore((s) => s.submittedNetlist !== ''); const interactionRunning = running || (boards.length === 0 && electricalEngaged && !electricalPaused); // 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 interactionRunningRef = useRef(interactionRunning); interactionRunningRef.current = interactionRunning; // When a run starts the canvas becomes interact-only — drop any edit // selection so leftover wire/segment handles don't linger over the circuit. useEffect(() => { if (interactionRunning) { setSelectedWire(null); setSelectedComponentId(null); } }, [interactionRunning, setSelectedWire]); // Lets the touch handlers (defined in an earlier effect closure) reach the // drop-time breadboard seating declared further down. const seatDroppedComponentRef = useRef<(componentId: string) => void>(() => {}); const componentsRef = useRef(components); componentsRef.current = components; const boardPositionRef = useRef(boardPosition); boardPositionRef.current = boardPosition; const boardsRef = useRef(boards); boardsRef.current = boards; // 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); /** Active waypoint drag (free 2D move of a single bend point). */ const waypointDragRef = useRef<{ wireId: string; waypointIndex: number; originalWaypoints: { x: number; y: number }[]; isDragging: boolean; } | null>(null); const [waypointDragPreview, setWaypointDragPreview] = useState<{ wireId: string; waypoints: { x: number; y: number }[]; } | null>(null); const [alignmentGuides, setAlignmentGuides] = useState([]); /** Set to true during mouseup if a segment/waypoint drag committed, so onClick can skip selection. */ const segmentDragJustCommittedRef = useRef(false); // Set when the mouse-up handler already resolved this click into a wire // selection (click on the breadboard body over a wire) — the bubbled // canvas onClick must not re-toggle that selection. const wireSelectJustHandledRef = 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]); // Compute bend-point handles (one per waypoint) for the selected wire const waypointHandles = React.useMemo(() => { if (!selectedWireId) return []; const wire = wires.find((w) => w.id === selectedWireId); if (!wire) return []; // While the user is dragging a waypoint, render handles at the live preview // positions so the dot tracks the cursor instead of jumping at commit. const activeDrag = waypointDragPreview?.wireId === wire.id ? waypointDragPreview : null; const wps = activeDrag ? activeDrag.waypoints : (wire.waypoints ?? []); return wps.map((wp, i) => ({ index: i, x: wp.x, y: wp.y })); }, [selectedWireId, wires, waypointDragPreview]); // 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 }); // Refs for touch-based wire creation, selection, and interactive passthrough const wireInProgressRef = useRef(wireInProgress); wireInProgressRef.current = wireInProgress; const selectedWireIdRef = useRef(selectedWireId); selectedWireIdRef.current = selectedWireId; const touchPassthroughRef = useRef(false); // While `touchPassthroughRef` is true (touch let through to an interactive // wokwi-element while the simulation runs), we still remember which // component the touch started on. If the finger drifts past // DRAG_PROMOTE_THRESHOLD_PX, we cancel the passthrough and start a real // drag — so the user can rearrange interactive parts live without first // pausing the simulation. const pendingTouchDragRef = useRef< { componentId: string; startX: number; startY: number } | null >(null); const touchOnPinRef = useRef(false); const lastTapTimeRef = useRef(0); // Long-press (touch-equivalent of right-click) — opens board context menu on // touch devices since right-click isn't available there. const longPressTimerRef = useRef | null>(null); const longPressFiredRef = useRef(false); const cancelLongPress = useCallback(() => { if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; } }, []); // 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]); // Runtime parts (pots, switches, sensor panels) emit // `velxio:property-change` instead of writing the store directly — one // listener here routes every mutation through `updateComponent()`, which // is the same path the Property Dialog uses. Keeps parts decoupled from // Zustand and guarantees the SPICE netlist memo invalidates on every // user-driven property change. useEffect(() => { const onPropertyChange = (evt: Event) => { const { componentId, propName, value } = (evt as CustomEvent).detail; const state = useSimulatorStore.getState(); const comp = state.components.find((c) => c.id === componentId); if (!comp) return; if (String(comp.properties?.[propName]) === String(value)) return; state.updateComponent(componentId, { properties: { ...comp.properties, [propName]: value }, }); }; window.addEventListener(PROPERTY_CHANGE_EVENT, onPropertyChange); return () => window.removeEventListener(PROPERTY_CHANGE_EVENT, onPropertyChange); }, []); // Digital-gate engine (project/digital-gate-engine): when ?digitalgates=on and // the board-less circuit is all-digital, evaluate the logic gates on the // event-driven settle kernel and paint the LEDs, instead of ngspice B-sources. // No-op when the flag is off (default). useEffect(() => mountDigitalGateEngine(), []); // 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 === 'raspberry-pi-4' || b.boardKind === 'raspberry-pi-5' || 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, component drag, wire creation/selection, and two-finger pinch-to-zoom. useEffect(() => { const el = canvasRef.current; if (!el) return; const onTouchStart = (e: TouchEvent) => { // Reset per-gesture flags touchOnPinRef.current = false; touchPassthroughRef.current = false; pendingTouchDragRef.current = null; pinchStartDistRef.current = 0; if (e.touches.length === 2) { e.preventDefault(); // Cancel wire in progress and any pending long-press on two-finger gesture cancelLongPress(); if (wireInProgressRef.current) { useSimulatorStore.getState().cancelWireCreation(); } // 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]; // Identify what element was touched const target = document.elementFromPoint(touch.clientX, touch.clientY); // ── 1. Pin overlay → let pin's onTouchEnd React handler call handlePinClick ── if (target?.closest('[data-pin-overlay]')) { e.preventDefault(); touchOnPinRef.current = true; return; } // ── 1b. Wire segment/waypoint handle → let WireLayer's React handler claim the drag. // We must skip the canvas-level pan/drag setup so the handle drag works in isolation. if (target?.closest('[data-wire-handle]')) { e.preventDefault(); touchPassthroughRef.current = true; return; } // ── 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. // Board-less SPICE circuits piggy-back on the same path so a tap on // a slide-switch reaches the wokwi element and triggers its toggle. // We still remember the starting position + component id so that if // the finger drifts past DRAG_PROMOTE_THRESHOLD_PX onTouchMove can // cancel the passthrough and start a real drag, letting the user // rearrange interactive parts live without pausing the simulation. if (interactionRunningRef.current) { const webComp = target?.closest('.web-component-container'); if (webComp) { touchPassthroughRef.current = true; const componentWrapper = target?.closest('[data-component-id]') as HTMLElement | null; const cid = componentWrapper?.getAttribute('data-component-id') || null; pendingTouchDragRef.current = cid ? { componentId: cid, startX: touch.clientX, startY: touch.clientY } : null; // Don't preventDefault → browser synthesizes mouse events for the component return; } } e.preventDefault(); touchClickStartTimeRef.current = Date.now(); touchClickStartPosRef.current = { x: touch.clientX, y: touch.clientY }; // ── 3. Wire in progress → track for waypoint, update preview ── if (wireInProgressRef.current) { 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 return; } // ── 4. Component detection ── const componentWrapper = target?.closest('[data-component-id]') as HTMLElement | null; const boardOverlay = target?.closest('[data-board-overlay]') as HTMLElement | null; if (componentWrapper) { 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) { // ── 5. Board overlay: use multi-board path ── const boardId = boardOverlay.getAttribute('data-board-id'); const storeBoards = useSimulatorStore.getState().boards; const boardInstance = boardId ? storeBoards.find((b) => b.id === boardId) : null; if (boardInstance) { const world = toWorld(touch.clientX, touch.clientY); touchDraggedComponentIdRef.current = `__board__:${boardId}`; touchDragOffsetRef.current = { x: world.x - boardInstance.x, y: world.y - boardInstance.y, }; // Schedule long-press to open the board context menu (touch // equivalent of right-click). Cancelled if the user moves enough // to start a drag, ends touch quickly, or starts a pinch. longPressFiredRef.current = false; cancelLongPress(); const pressX = touch.clientX; const pressY = touch.clientY; const targetBoardId = boardInstance.id; longPressTimerRef.current = setTimeout(() => { longPressFiredRef.current = true; // Cancel the implicit drag so finger lifting after the menu opens // doesn't also fire the short-tap "set active board" branch. touchDraggedComponentIdRef.current = null; setBoardContextMenu({ boardId: targetBoardId, x: pressX, y: pressY }); }, LONG_PRESS_MS); } else { // Fallback to legacy single board 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 { // ── 6. 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) => { // Let interactive components handle their own touch (potentiometer drag, etc.) // — UNLESS the finger has drifted past DRAG_PROMOTE_THRESHOLD_PX, // in which case we cancel the passthrough and start a real component // drag. Lets users rearrange parts during simulation without pausing. if (touchPassthroughRef.current) { const pending = pendingTouchDragRef.current; if (pending && e.touches.length === 1) { const t = e.touches[0]; const dx = t.clientX - pending.startX; const dy = t.clientY - pending.startY; if (dx * dx + dy * dy > DRAG_PROMOTE_THRESHOLD_PX * DRAG_PROMOTE_THRESHOLD_PX) { const component = componentsRef.current.find((c) => c.id === pending.componentId); if (component) { const world = toWorld(t.clientX, t.clientY); touchDraggedComponentIdRef.current = pending.componentId; touchDragOffsetRef.current = { x: world.x - component.x, y: world.y - component.y, }; // Snapshot the drag start position so touchend can fold the // move into a single undoable Move command, mirroring the // mouse-drag path. dragStartPosRef.current = { x: component.x, y: component.y }; touchClickStartTimeRef.current = Date.now(); touchClickStartPosRef.current = { x: t.clientX, y: t.clientY }; // Release any pending press the wokwi-element registered when // the browser synthesized the initial mousedown at touchstart. // Without this the interactive part (button, switch knob) stays // visually held until the user taps it again. const target = document.elementFromPoint(pending.startX, pending.startY); if (target) { target.dispatchEvent( new MouseEvent('mouseup', { bubbles: true, cancelable: true, clientX: pending.startX, clientY: pending.startY, }), ); target.dispatchEvent( new MouseEvent('mouseleave', { bubbles: false }), ); } } touchPassthroughRef.current = false; pendingTouchDragRef.current = null; e.preventDefault(); // Fall through to normal touch-drag handling below. } else { return; } } else { return; } } // Pin touch: no move processing needed if (touchOnPinRef.current) { e.preventDefault(); return; } // Cancel pending long-press if the finger drifts beyond tolerance. if (longPressTimerRef.current && e.touches.length === 1) { const t = e.touches[0]; const dx = t.clientX - touchClickStartPosRef.current.x; const dy = t.clientY - touchClickStartPosRef.current.y; if (dx * dx + dy * dy > LONG_PRESS_MOVE_TOLERANCE * LONG_PRESS_MOVE_TOLERANCE) { cancelLongPress(); } } 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]; // ── Segment drag (wire editing) via touch ── if (segmentDragRef.current) { const world = toWorld(touch.clientX, touch.clientY); const sd = segmentDragRef.current; sd.isDragging = true; const newValue = sd.axis === 'horizontal' ? world.y : world.x; const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue); const overridePath = renderedPointsToPath(newPts); setSegmentDragPreview({ wireId: sd.wireId, overridePath }); return; } // ── Wire preview: update position as finger moves ── if ( wireInProgressRef.current && !isPanningRef.current && !touchDraggedComponentIdRef.current ) { const world = toWorld(touch.clientX, touch.clientY); useSimulatorStore.getState().updateWireInProgress(world.x, world.y); return; } 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 { let nx = world.x - touchDragOffsetRef.current.x; let ny = world.y - touchDragOffsetRef.current.y; const simState = useSimulatorStore.getState(); const dragged = simState.components.find( (c) => c.id === touchDraggedComponentIdRef.current, ); if (dragged) { if ( isAutoVerticalPart(dragged.metadataId) && (Number(dragged.properties?.rotation) || 0) === 0 && isOverBreadboard(dragged, nx, ny, simState.components) ) { updateComponent(touchDraggedComponentIdRef.current!, { properties: { ...dragged.properties, rotation: 90 }, } as any); } const cur = useSimulatorStore .getState() .components.find((c) => c.id === touchDraggedComponentIdRef.current); const snapped = cur && snapPositionToBreadboard(cur, nx, ny, simState.components); if (snapped) { nx = snapped.x; ny = snapped.y; } } updateComponent(touchDraggedComponentIdRef.current!, { x: nx, y: ny, } as any); } } }; const onTouchEnd = (e: TouchEvent) => { // Always clear any pending long-press timer on touch release. cancelLongPress(); // If the long-press fired and opened a context menu, swallow this // touchend so we don't also fire the short-tap action below. if (longPressFiredRef.current) { longPressFiredRef.current = false; touchDraggedComponentIdRef.current = null; e.preventDefault(); return; } // Let interactive components handle their own touch if (touchPassthroughRef.current) { touchPassthroughRef.current = false; pendingTouchDragRef.current = null; return; } // Pin touch: let pin's onTouchEnd React handler deal with it if (touchOnPinRef.current) { touchOnPinRef.current = false; e.preventDefault(); return; } 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 segment drag (wire editing) via touch ── if (segmentDragRef.current) { const sd = segmentDragRef.current; if (sd.isDragging) { segmentDragJustCommittedRef.current = true; const changed = e.changedTouches[0]; if (changed) { const world = toWorld(changed.clientX, changed.clientY); const newValue = sd.axis === 'horizontal' ? world.y : world.x; const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue); updateWire(sd.wireId, { waypoints: renderedToWaypoints(newPts), autoRouted: false }); } } segmentDragRef.current = null; setSegmentDragPreview(null); 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; const dist = Math.sqrt(dx * dx + dy * dy); const isShortTap = dist < 20 && elapsed < 400; // If we actually panned (moved significantly), don't process as tap if (wasPanning && !isShortTap) return; // ── Finish component/board drag ── if (touchDraggedComponentIdRef.current) { const touchId = touchDraggedComponentIdRef.current; if (isShortTap) { if (touchId.startsWith('__board__:')) { // Short tap on board: first tap → make it active (so pins show); // tap again on the same board → open the touch-friendly pin // picker so the user can start a wire by name without poking at // a tiny pin overlay with a finger. const boardId = touchId.slice('__board__:'.length); const state = useSimulatorStore.getState(); if (state.activeBoardId === boardId) { setPinPicker({ kind: 'board', targetId: boardId }); } else { state.setActiveBoardId(boardId); } } else if (touchId !== '__board__') { // Short tap on component → open property dialog or sensor panel. // While the simulator is running (MCU or board-less SPICE), // components stay interactive — only sensor panels may open; // property editing is disabled so the tap reaches the // wokwi-element underneath and toggles it. const component = componentsRef.current.find((c) => c.id === touchId); if (component) { if (interactionRunningRef.current) { if (SENSOR_CONTROLS[component.metadataId] !== undefined) { setSensorControlComponentId(touchId); setSensorControlMetadataId(component.metadataId); } } else { setPropertyDialogComponentId(touchId); setPropertyDialogPosition({ x: component.x * zoomRef.current + panRef.current.x, y: component.y * zoomRef.current + panRef.current.y, }); setShowPropertyDialog(true); } } } } else if (touchId !== '__board__' && !touchId.startsWith('__board__:')) { // Real touch drag (not a tap) — seat it properly on release, // same as the mouse path. seatDroppedComponentRef.current(touchId); } recalculateAllWirePositions(); touchDraggedComponentIdRef.current = null; return; } // ── Wire in progress: short tap adds waypoint OR opens pin picker ── // If the user tapped on a component / board body (not a pin overlay), // open the touch-friendly pin picker so they can finish the wire by // tapping a pin name from a list — far more reliable than poking at a // 12px overlay with a fingertip. Empty-canvas taps still drop waypoints. if (wireInProgressRef.current) { if (isShortTap) { const tapTarget = document.elementFromPoint(changed.clientX, changed.clientY); const componentWrapper = tapTarget?.closest('[data-component-id]'); const boardOverlay = tapTarget?.closest('[data-board-overlay]'); if (componentWrapper) { const id = componentWrapper.getAttribute('data-component-id'); if (id) setPinPicker({ kind: 'component', targetId: id }); } else if (boardOverlay) { const id = boardOverlay.getAttribute('data-board-id'); if (id) setPinPicker({ kind: 'board', targetId: id }); } else { const world = toWorld(changed.clientX, changed.clientY); useSimulatorStore.getState().addWireWaypoint(world.x, world.y); } } return; } // ── Short tap on empty canvas: wire selection + double-tap inserts waypoint ── // Disabled while running — the canvas is interact-only then. if (isShortTap && !interactionRunningRef.current) { const now = Date.now(); const world = toWorld(changed.clientX, changed.clientY); const baseThreshold = isTouchDeviceRef.current ? 20 : 8; const threshold = baseThreshold / zoomRef.current; const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold); // Double-tap on a wire → insert draggable waypoint at the tap location const timeSinceLastTap = now - lastTapTimeRef.current; if (timeSinceLastTap < 350 && wire) { const seg = findSegmentNearPoint(wire, world.x, world.y, threshold); if (seg) { const newWaypoints = insertWaypointAtSegment( wire.waypoints ?? [], seg, world.x, world.y, ); useSimulatorStore.getState().updateWire(wire.id, { waypoints: newWaypoints, autoRouted: false }); useSimulatorStore.getState().setSelectedWire(wire.id); } lastTapTimeRef.current = 0; return; } lastTapTimeRef.current = now; if (wire) { const curr = selectedWireIdRef.current; useSimulatorStore.getState().setSelectedWire(curr === wire.id ? null : wire.id); } else { useSimulatorStore.getState().setSelectedWire(null); 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); cancelLongPress(); }; }, [toWorld, setBoardPosition, updateComponent, recalculateAllWirePositions, cancelLongPress]); // 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)[] = []; // Returns true if the component has at least one wire connected to a board // GND or power-rail pin (boardPinToNumber returns -1 for these). // Used to block output components from activating without a ground connection. const componentHasGndWire = (component: any): boolean => wires.some((w) => { const isSelfStart = w.start.componentId === component.id; const isSelfEnd = w.end.componentId === component.id; if (!isSelfStart && !isSelfEnd) return false; const otherEndpoint = isSelfStart ? w.end : w.start; if (!isBoardComponent(otherEndpoint.componentId)) return false; const boardInstance = boards.find((b) => b.id === otherEndpoint.componentId); const lookupKey = boardInstance ? boardInstance.boardKind : otherEndpoint.componentId; return boardPinToNumber(lookupKey, otherEndpoint.pinName) === -1; }); // Helper to add subscription // wireConnected: true when this call came from the wire-scanning path (not properties.pin). const subscribeComponentToPin = ( component: any, pin: number, componentPinName?: string, wireConnected = false, ) => { // Components with attachEvents in PartSimulationRegistry manage their own // visual state (e.g. LED, servo, buzzer). Skip generic digital/PWM updates for // them — they already handle GND logic internally via getArduinoPinHelper. // // SPICE-mapped components (every analog/mixed part — resistors, capacitors, // diodes, MOSFETs, op-amps, regulators, …) are ALSO treated as self-managed: // SPICE is the authoritative source for their electrical state. If we echoed // the raw digital pin value back into `components[i].properties.state` on // every toggle, the store update would invalidate the solver debounce every // ~2 ms under PWM (490 Hz) and the solver would effectively never run — the // root cause of the MOSFET-PWM-LED regression. This single rule makes every // current and future SPICE mapper immune to that feedback loop. const logic = PartSimulationRegistry.get(component.metadataId); const spiceOwned = isSpiceMapped(component.metadataId); // Breadboards have no visual on/off state, but they ARE direct-wired to // many board pins (one wire per strip → GPIO). Writing properties.state // per edge minted a new components array thousands of times per second // on multiplexed sketches — pure churn that re-rendered the whole // editor. Treat them as self-managed: skip the generic state echo. const hasSelfManagedVisuals = !!(logic && logic.attachEvents) || spiceOwned || isBreadboard(component.metadataId); // Generic GND check: for wire-connected output components that don't manage // their own state, require at least one GND wire before activating. // Skip the check for pin-property components (no GND wire to detect) and for // self-managed components (they handle GND themselves via attachEvents). const hasGnd = !wireConnected || hasSelfManagedVisuals ? true : componentHasGndWire(component); const unsubscribe = pinManager.onPinChange(pin, (_pin, state) => { if (!hasSelfManagedVisuals) { // Update React state — gate on GND for wire-connected components. updateComponentState(component.id, hasGnd && state); } // Delegate to PartSimulationRegistry for custom visual updates if (logic && logic.onPinStateChange) { const el = document.getElementById(component.id); if (el) { logic.onPinStateChange(componentPinName || 'A', hasGnd && state, el); } } }); unsubscribers.push(unsubscribe); }; components.forEach((component) => { // 1. Subscribe by explicit pin property (old-style, no wire needed) if (component.properties.pin !== undefined) { subscribeComponentToPin(component, component.properties.pin as number, 'A', false); } else { // 2. Subscribe by finding wires connected to arduino const connectedWires = 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; // Recognise board endpoints from the LIVE boards list first — // isBoardComponent only matches static id prefixes, so every // runtime-added board (agent-minted UUID ids) failed it and its // directly-wired components were never subscribed. const boardInstance = boards.find((b) => b.id === otherEndpoint.componentId); if (boardInstance || 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 lookupKey = boardInstance ? boardInstance.boardKind : otherEndpoint.componentId; const pin = boardPinToNumber(lookupKey, otherEndpoint.pinName); if (pin !== null && pin >= 0) { subscribeComponentToPin(component, pin, selfEndpoint.pinName, true); } else if (pin === null) { console.warn( `[WirePin] Could not resolve pin "${otherEndpoint.pinName}" on ${lookupKey}`, ); } // pin === -1 → power/GND pin, skip silently } }); } }); return () => { unsubscribers.forEach((unsub) => unsub()); }; }, [components, wires, boards, pinManager, updateComponentState]); // Board built-in LED: subscribe directly to pinManager for the LED pin of each board. // This works even when no external led-builtin component exists (e.g. basic Blink example). useEffect(() => { if (!pinManager) return; const unsubs: (() => void)[] = []; boards.forEach((board) => { // Determine which GPIO pin drives the board's built-in LED let ledPin: number; switch (board.boardKind) { case 'raspberry-pi-pico': case 'pi-pico-w': case 'nano-rp2040': ledPin = 25; // GPIO25 break; case 'attiny85': ledPin = 1; // PB1 (Digispark convention) break; default: ledPin = 13; // Pin 13 for Arduino Uno/Nano/Mega } unsubs.push( pinManager.onPinChange(ledPin, (_pin, state) => { setBoardLedStates((prev) => { if (prev[board.id] === state) return prev; return { ...prev, [board.id]: state }; }); }), ); }); return () => unsubs.forEach((u) => u()); }, [boards, pinManager]); // ESP32 input components: forward button presses and potentiometer values to QEMU useEffect(() => { const cleanups: (() => void)[] = []; components.forEach((component) => { const connectedWires = 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)) return; const boardId = otherEndpoint.componentId; const bridge = getEsp32Bridge(boardId); if (!bridge) return; // not an ESP32 board const boardInstance = boards.find((b) => b.id === boardId); const lookupKey = boardInstance ? boardInstance.boardKind : boardId; const gpioPin = boardPinToNumber(lookupKey, otherEndpoint.pinName); if (gpioPin === null) return; // Delay lookup so the web component has time to render const timeout = setTimeout(() => { const el = document.getElementById(component.id); if (!el) return; const tag = el.tagName.toLowerCase(); // Push-button: forward press/release as GPIO level changes if (tag === 'wokwi-pushbutton') { const onPress = () => bridge.sendPinEvent(gpioPin, true); const onRelease = () => bridge.sendPinEvent(gpioPin, false); el.addEventListener('button-press', onPress); el.addEventListener('button-release', onRelease); cleanups.push(() => { el.removeEventListener('button-press', onPress); el.removeEventListener('button-release', onRelease); }); } // Potentiometer: forward analog value as ADC millivolts if (tag === 'wokwi-potentiometer' && selfEndpoint.pinName === 'SIG') { const adcInfo = ESP32_ADC_PIN_MAP[gpioPin]; if (adcInfo) { const onInput = (e: Event) => { const pct = parseFloat((e.target as any).value ?? '0'); // 0–100 bridge.setAdc(adcInfo.chn, Math.round((pct / 100) * 3300)); }; el.addEventListener('input', onInput); cleanups.push(() => el.removeEventListener('input', onInput)); } } }, 300); cleanups.push(() => clearTimeout(timeout)); }); }); return () => cleanups.forEach((fn) => fn()); }, [components, wires, boards]); // Handle keyboard delete for the selected component useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Skip when the user is typing in an input/textarea/contenteditable — // otherwise Backspace inside the AI chat (or any future text field) // would also delete the selected component. const t = e.target as HTMLElement | null; if (t) { const tag = t.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) { return; } } if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedComponentId) { // Recorded so the user can Ctrl+Z this back. Cascades wire removal too. recordRemoveComponent(selectedComponentId); setSelectedComponentId(null); } // The board is intentionally NOT deletable via Delete/Backspace. It is // always the "active" board (its code is shown in the editor), so keying // off activeBoardId here popped the board-removal confirmation whenever // the user pressed Delete to remove a wire, or after they had just // deleted a component. Board removal stays on the explicit, deliberate // paths: the right-click "Remove board" context menu and the touch // pin-picker delete action. } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedComponentId, recordRemoveComponent]); // Handle component selection from modal const handleSelectComponent = (metadata: ComponentMetadata) => { // Anchor new components to the visible top-left of the canvas, so they // appear in the user's current viewport regardless of pan/zoom (instead // of growing off-screen at fixed world coords like (400, 100 + row*250)). const rect = canvasRef.current?.getBoundingClientRect(); const z = zoomRef.current || 1; const screenMargin = 60; // px on screen — keeps the part off the toolbar/edge const worldOrigin = rect ? toWorld(rect.left + screenMargin, rect.top + screenMargin) : { x: 100, y: 100 }; // Tile additional drops so they don't stack exactly on top of each other, // while still landing inside the viewport. const tileStep = 40 / z; // 40 screen-px between successive drops const cols = 4; const idx = components.length; const x = worldOrigin.x + (idx % cols) * tileStep; const y = worldOrigin.y + Math.floor(idx / cols) * tileStep; const component = createComponentFromMetadata(metadata, x, y); trackAddComponent(metadata.id); // Recorded — user can Ctrl+Z to remove the just-added component. recordAddComponent(component as Parameters[0]); setShowComponentPicker(false); // Custom Chips need a compile step before they can do anything — open the // designer dialog immediately so the user lands in the editor. if (metadata.id === 'custom-chip') { setCustomChipComponentId(component.id); } }; // Component rotation — applies the new angle and records it as a single // undoable command (round-trip flips the rotation property both ways). const handleRotateComponent = (componentId: string) => { const component = components.find((c) => c.id === componentId); if (!component) return; const currentRotation = (component.properties.rotation as number) || 0; const nextRotation = (currentRotation + 90) % 360; updateComponent(componentId, { properties: { ...component.properties, rotation: nextRotation, }, } as any); recordRotate(componentId, currentRotation, nextRotation); }; // Component dragging handlers const handleComponentMouseDown = (componentId: string, e: React.MouseEvent) => { if (showPropertyDialog) return; // While running, the canvas is read-only and most components // (pushbutton, switch, pot, …) need the raw event so their // wokwi-element shadow DOM can fire button-press / change events — // we let the mousedown propagate. Sensors are the exception: their // only interaction is the SensorControlPanel we open ourselves, so // we still claim the click for them (mouseUp opens the panel via // the SENSOR_CONTROLS branch). Without this, sensor clicks during a // run bubble to the canvas pan handler instead (grab cursor, no // panel). Mirrors the touch tap flow above. if (interactionRunning) { const component = components.find((c) => c.id === componentId); const isSensor = !!component && SENSOR_CONTROLS[component.metadataId] !== undefined; if (!isSensor) 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, }); // Snapshot the starting position. mouseup uses this to push a single // Move command if the component actually moved (vs being a click). dragStartPosRef.current = { x: component.x, 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 { let nx = world.x - dragOffset.x; let ny = world.y - dragOffset.y; const simState = useSimulatorStore.getState(); const dragged = simState.components.find((c) => c.id === draggedComponentId); if (dragged) { // Axial parts entering a breadboard flip to vertical so they // bridge the trench columns like on a real board. if ( isAutoVerticalPart(dragged.metadataId) && (Number(dragged.properties?.rotation) || 0) === 0 && isOverBreadboard(dragged, nx, ny, simState.components) ) { updateComponent(draggedComponentId, { properties: { ...dragged.properties, rotation: 90 }, } as any); } const cur = useSimulatorStore .getState() .components.find((c) => c.id === draggedComponentId); const snapped = cur && snapPositionToBreadboard(cur, nx, ny, simState.components); if (snapped) { nx = snapped.x; ny = snapped.y; } } updateComponent(draggedComponentId, { x: nx, y: ny } 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 threshold = ALIGN_SNAP_PX / zoomRef.current; const targets = collectAlignmentTargets(wiresRef.current, sd.wireId); // Snap against the wire's own runs too, so a dragged segment can // line up with (and fuse into) its neighbours instead of ending // up millimetres off. addOwnWireAlignmentTargets(targets, sd.renderedPts, [sd.segIndex, sd.segIndex + 1]); const guides: AlignmentGuide[] = []; let newValue = sd.axis === 'horizontal' ? world.y : world.x; const snap = snapToNearest( newValue, sd.axis === 'horizontal' ? targets.ys : targets.xs, threshold, ); if (snap) { newValue = snap.snapped; guides.push({ axis: sd.axis === 'horizontal' ? 'y' : 'x', value: snap.target }); } setAlignmentGuides(guides); const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue); const overridePath = renderedPointsToPath(simplifyOrthogonalPath(newPts)); setSegmentDragPreview({ wireId: sd.wireId, overridePath }); return; } // Handle waypoint handle dragging (free 2D move) if (waypointDragRef.current) { const world = toWorld(e.clientX, e.clientY); const wd = waypointDragRef.current; wd.isDragging = true; const wire = wiresRef.current.find((w) => w.id === wd.wireId); if (wire) { const threshold = ALIGN_SNAP_PX / zoomRef.current; const targets = collectAlignmentTargets(wiresRef.current, wd.wireId); // Own-wire targets (start, end, other bends) minus the dragged // bend itself, so it can fuse back onto its own wire's lines. addOwnWireAlignmentTargets( targets, [ { x: wire.start.x, y: wire.start.y }, ...wd.originalWaypoints, { x: wire.end.x, y: wire.end.y }, ], [wd.waypointIndex + 1], ); const guides: AlignmentGuide[] = []; let snappedX = world.x; let snappedY = world.y; const snapX = snapToNearest(world.x, targets.xs, threshold); if (snapX) { snappedX = snapX.snapped; guides.push({ axis: 'x', value: snapX.target }); } const snapY = snapToNearest(world.y, targets.ys, threshold); if (snapY) { snappedY = snapY.snapped; guides.push({ axis: 'y', value: snapY.target }); } setAlignmentGuides(guides); const newWaypoints = wd.originalWaypoints.map((wp, i) => i === wd.waypointIndex ? { x: snappedX, y: snappedY } : { ...wp }, ); setWaypointDragPreview({ wireId: wd.wireId, waypoints: newWaypoints }); // Reflect the moved bend point in the rendered path live const expanded = expandOrthogonalPoints([ { x: wire.start.x, y: wire.start.y }, ...newWaypoints, { x: wire.end.x, y: wire.end.y }, ]); const overridePath = renderedPointsToPath(simplifyOrthogonalPath(expanded)); setSegmentDragPreview({ wireId: wd.wireId, overridePath }); } return; } // Wire hover detection (when not dragging anything). Skipped while running — // wires aren't selectable then, so they shouldn't highlight as hoverable. if (!draggedComponentId && !interactionRunningRef.current) { 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); } }; /** * Drop-time breadboard seating. The drag-time magnet only aligns the * anchor pin, which is how parts ended up HALF-seated (some pins in * holes, the rest dead in the air). On release we re-solve properly: * nearest position where every pin is in a free hole, sliding past * occupied columns if needed. Leaves the part untouched when it is not * over a board or genuinely does not fit. */ const seatDroppedComponent = (componentId: string) => { const state = useSimulatorStore.getState(); const comp = state.components.find((c) => c.id === componentId); if (!comp) return; const placement = seatOnDrop(comp, comp.x, comp.y, state.components); if (!placement || placement.moved < 0.01) return; updateComponent(componentId, { x: placement.x, y: placement.y } as any); }; seatDroppedComponentRef.current = seatDroppedComponent; 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 threshold = ALIGN_SNAP_PX / zoomRef.current; const targets = collectAlignmentTargets(wiresRef.current, sd.wireId); addOwnWireAlignmentTargets(targets, sd.renderedPts, [sd.segIndex, sd.segIndex + 1]); let newValue = sd.axis === 'horizontal' ? world.y : world.x; const snap = snapToNearest( newValue, sd.axis === 'horizontal' ? targets.ys : targets.xs, threshold, ); if (snap) newValue = snap.snapped; const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue); updateWire(sd.wireId, { waypoints: renderedToWaypoints(newPts), autoRouted: false }); } segmentDragRef.current = null; setSegmentDragPreview(null); setAlignmentGuides([]); return; } // Commit waypoint handle drag if (waypointDragRef.current) { const wd = waypointDragRef.current; if (wd.isDragging) { segmentDragJustCommittedRef.current = true; const world = toWorld(e.clientX, e.clientY); const wire = wiresRef.current.find((w) => w.id === wd.wireId); if (wire) { const threshold = ALIGN_SNAP_PX / zoomRef.current; const targets = collectAlignmentTargets(wiresRef.current, wd.wireId); addOwnWireAlignmentTargets( targets, [ { x: wire.start.x, y: wire.start.y }, ...wd.originalWaypoints, { x: wire.end.x, y: wire.end.y }, ], [wd.waypointIndex + 1], ); let snappedX = world.x; let snappedY = world.y; const snapX = snapToNearest(world.x, targets.xs, threshold); if (snapX) snappedX = snapX.snapped; const snapY = snapToNearest(world.y, targets.ys, threshold); if (snapY) snappedY = snapY.snapped; const newWaypoints = wd.originalWaypoints.map((wp, i) => i === wd.waypointIndex ? { x: snappedX, y: snappedY } : { ...wp }, ); // Run through expand → simplify so collinear waypoints get cleaned up const expanded = expandOrthogonalPoints([ { x: wire.start.x, y: wire.start.y }, ...newWaypoints, { x: wire.end.x, y: wire.end.y }, ]); updateWire(wd.wireId, { waypoints: renderedToWaypoints(expanded), autoRouted: false }); } } waypointDragRef.current = null; setWaypointDragPreview(null); setSegmentDragPreview(null); setAlignmentGuides([]); 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) { if (draggedComponentId.startsWith('__board__:')) { // Click on a board — make it the active board (editor switches to its code) const boardId = draggedComponentId.slice('__board__:'.length); useSimulatorStore.getState().setActiveBoardId(boardId); } else if (draggedComponentId !== '__board__') { const component = components.find((c) => c.id === draggedComponentId); if (component) { if (interactionRunning) { // During simulation (MCU running OR board-less SPICE active) // only sensor panels open on click — every other component // is interactive (pushbutton, switch, pot, …) and must // handle its own clicks, so we suppress the property // dialog entirely. This is the path that also unblocks // wokwi-slide-switch toggling in the digital examples. if (SENSOR_CONTROLS[component.metadataId] !== undefined) { setSensorControlComponentId(draggedComponentId); setSensorControlMetadataId(component.metadataId); } } else if (component.metadataId === 'custom-chip') { // Custom Chips have their own designer (C editor + chip.json + Compile). setCustomChipComponentId(draggedComponentId); } else if ( isBreadboard(component.metadataId) && (() => { // A wire crossing the breadboard body sits visually ON TOP of // it — clicking the wire must select the wire, not bury it // under the breadboard's full-pin-list property dialog // (reported: bb→bb wires were unselectable, the hole list // popped over everything instead). const world = toWorld(e.clientX, e.clientY); const nearWire = findWireNearPoint( wiresRef.current, world.x, world.y, 8 / zoomRef.current, ); if (nearWire) { setSelectedWire(nearWire.id); // The subsequent canvas onClick would re-hit the same wire // and toggle it back OFF — suppress that one click. wireSelectJustHandledRef.current = true; return true; } return false; })() ) { // handled — wire selected instead of opening the dialog } else { setPropertyDialogComponentId(draggedComponentId); setPropertyDialogPosition({ x: component.x * zoomRef.current + panRef.current.x, y: component.y * zoomRef.current + panRef.current.y, }); setShowPropertyDialog(true); } } } } // If this was a real drag (not a click), commit one Move command // so undo can roll the position back. Click-without-drag short- // circuits via the (posDiff < 5 && timeDiff < 300) branch above // and just opens the property dialog — no history entry needed. const start = dragStartPosRef.current; const isClick = posDiff < 5 && timeDiff < 300; if ( !isClick && start && draggedComponentId && !draggedComponentId.startsWith('__board__') ) { // Seat BEFORE recording the move, so undo restores the pre-drag // position in one step instead of leaving the part mid-seat. seatDroppedComponent(draggedComponentId); // Re-read from the store: `components` is the render-time closure // and does not include the seating correction just applied. const moved = useSimulatorStore .getState() .components.find((c) => c.id === draggedComponentId); if (moved && (moved.x !== start.x || moved.y !== start.y)) { recordMove(draggedComponentId, start, { x: moved.x, y: moved.y }); } } dragStartPosRef.current = null; recalculateAllWirePositions(); setDraggedComponentId(null); } }; // Start panning on middle-click or right-click const handleCanvasMouseDown = (e: React.MouseEvent) => { // Middle / right click — always pan, regardless of context. // Left click — pan only when clicking ON THE BACKGROUND (the event // reaches the canvas because component mousedowns stopPropagation), // and only when not wiring (wire mode uses left click to drop // waypoints) or running a property dialog. Matches the diagram-editor // convention used in Figma / Miro / draw.io. const leftButton = e.button === 0; const middleOrRight = e.button === 1 || e.button === 2; const isPanGesture = middleOrRight || (leftButton && !wireInProgress && !showPropertyDialog); if (isPanGesture) { 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 (interactionRunningRef.current) return; // interact-only while running 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], ); // Handle touchstart on a segment handle circle (mobile wire editing) const handleHandleTouchStart = useCallback( (e: React.TouchEvent, segIndex: number) => { e.stopPropagation(); if (interactionRunningRef.current) return; 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], ); // Handle mousedown on a waypoint (bend-point) handle: free 2D drag const handleWaypointMouseDown = useCallback( (e: React.MouseEvent, waypointIndex: number) => { e.stopPropagation(); e.preventDefault(); if (interactionRunningRef.current) return; // interact-only while running if (!selectedWireId) return; const wire = wiresRef.current.find((w) => w.id === selectedWireId); if (!wire) return; waypointDragRef.current = { wireId: wire.id, waypointIndex, originalWaypoints: (wire.waypoints ?? []).map((wp) => ({ ...wp })), isDragging: false, }; }, [selectedWireId], ); // Handle touchstart on a waypoint handle (mobile) const handleWaypointTouchStart = useCallback( (e: React.TouchEvent, waypointIndex: number) => { e.stopPropagation(); if (interactionRunningRef.current) return; if (!selectedWireId) return; const wire = wiresRef.current.find((w) => w.id === selectedWireId); if (!wire) return; waypointDragRef.current = { wireId: wire.id, waypointIndex, originalWaypoints: (wire.waypoints ?? []).map((wp) => ({ ...wp })), 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) => { // No making connections while the simulation runs — interact-only. if (interactionRunningRef.current) return; // Close property dialog when starting wire creation if (showPropertyDialog) { setShowPropertyDialog(false); } // ── Breadboard hole rules: one hole, one wire ────────────────────── // A hole already holding a visible wire end can't start another wire — // clicking it SELECTS that wire instead. (Without this, a wire whose // endpoints sit in holes is impossible to select: the hole overlays // swallow every click and silently start a new wire.) When a new end // does land in an occupied hole (seated leg or another wire), it // shifts to the nearest free hole of the same 5-hole strip / rail — // electrically identical, visually untangled. const bbComp = componentsRef.current.find((c) => c.id === componentId); const isBBHole = !!bbComp && isBreadboard(bbComp.metadataId); if (isBBHole) { if (!wireInProgress) { const occupying = findWireAtHole(wiresRef.current, componentId, pinName); if (occupying) { setSelectedWire(occupying.id); return; } } const el = document.getElementById(componentId) as | (HTMLElement & { pinInfo?: Array<{ name: string }> }) | null; const allNames = (el?.pinInfo ?? []).map((p) => p.name); const free = resolveFreeHole( bbComp.metadataId, componentId, pinName, wiresRef.current, allNames, ); if (free !== pinName) { const rot = Number(bbComp.properties?.rotation) || 0; const pos = calculatePinPosition(componentId, free, bbComp.x + 6, bbComp.y + 6, rot); if (pos) { pinName = free; x = pos.x; y = pos.y; } } } if (wireInProgress) { // Finish wire: the store atomically appends the new wire and clears // `wireInProgress`. Once that's done, we look up the wire it just // created and push a CanvasCommand with applyNow:false (state is // already at the post-add state). Undo removes the wire; redo re-adds. finishWireCreation({ componentId, pinName, x, y }); trackCreateWire(); const wires = useSimulatorStore.getState().wires; const created = wires[wires.length - 1]; if (created) { useSimulatorStore.getState().pushCommand( { description: 'Add wire', execute: () => useSimulatorStore.getState().addWire(created), undo: () => useSimulatorStore.getState().removeWire(created.id), }, { applyNow: false }, ); } } else { // Start wire: rails mandate red (+) / black (−); other breadboard // holes pick a random jumper color (like a real jumper kit — a board // full of identical green wires is unreadable); component pins keep // the name-based auto color (GND → black, VCC → red, else green). const color = isBBHole ? (railWireColor(pinName) ?? WIRE_JUMPER_PALETTE[Math.floor(Math.random() * WIRE_JUMPER_PALETTE.length)]) : autoWireColor(pinName); startWireCreation({ componentId, pinName, x, y }, color); } }; // Keyboard handlers for wires useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Escape → cancel in-progress wire (works even while a field is focused). if (e.key === 'Escape' && wireInProgress) { cancelWireCreation(); return; } // Skip the rest when the user is typing in an input/textarea/select/ // contenteditable — otherwise Backspace in a text field (e.g. the AI chat) // would delete the selected wire, and the color-shortcut keys below would // hijack normal typing. const t = e.target as HTMLElement | null; if (t) { const tag = t.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) { return; } } // Delete / Backspace → remove selected wire (recorded for undo). if ((e.key === 'Delete' || e.key === 'Backspace') && selectedWireId) { recordRemoveWire(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, recordRemoveWire, 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/zoom to keep the board and all components 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). // // On touch-primary devices we also auto-fit the zoom — projects authored on // a desktop with a wide canvas otherwise show up cramped at zoom 1 on a // ~400px-wide phone, with everything piled into the top-left corner. const prevComponentCountRef = useRef(-1); useEffect(() => { const prev = prevComponentCountRef.current; const curr = components.length; prevComponentCountRef.current = curr; // Only re-fit 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(); // Read actual rendered sizes from the DOM so the fit accounts for each // board/component's true footprint, not a guessed bounding box. const z = zoomRef.current; const p = panRef.current; const toWorldRect = (r: DOMRect) => ({ x1: (r.left - rect.left - p.x) / z, y1: (r.top - rect.top - p.y) / z, x2: (r.right - rect.left - p.x) / z, y2: (r.bottom - rect.top - p.y) / z, }); const targets: HTMLElement[] = []; boardsRef.current.forEach((b) => { const el = canvas.querySelector(`[data-board-id="${b.id}"]`); if (el) targets.push(el); }); componentsRef.current.forEach((c) => { const el = canvas.querySelector(`[data-component-id="${c.id}"]`); if (el) targets.push(el); }); if (targets.length === 0) return; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; targets.forEach((el) => { const wr = toWorldRect(el.getBoundingClientRect()); if (wr.x1 < minX) minX = wr.x1; if (wr.y1 < minY) minY = wr.y1; if (wr.x2 > maxX) maxX = wr.x2; if (wr.y2 > maxY) maxY = wr.y2; }); if (!isFinite(minX)) return; const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; const worldW = Math.max(1, maxX - minX); const worldH = Math.max(1, maxY - minY); // On touch-primary viewports, also adjust zoom so everything fits with // padding. On desktop, keep the existing behavior (pan only) so users // who set a custom zoom don't lose it on every load. let nextZoom = z; if (isTouchDeviceRef.current) { const PADDING = 32; const availW = Math.max(50, rect.width - PADDING * 2); const availH = Math.max(50, rect.height - PADDING * 2); // Never zoom *in* past 1× — small projects shouldn't get magnified. const fit = Math.min(availW / worldW, availH / worldH, 1); nextZoom = Math.max(0.2, fit); } const newPan = { x: rect.width / 2 - centerX * nextZoom, y: rect.height / 2 - centerY * nextZoom, }; zoomRef.current = nextZoom; panRef.current = newPan; setZoom(nextZoom); setPan(newPan); }, 200); return () => clearTimeout(timer); }, [components.length]); // Which pins of each component are plugged into a breadboard hole. Seating // is an invisible `bb` wire (component pin = start, hole = end), so one pass // over the wires gives every part its seated-pin names for the green markers. const seatedPinsByComponent = React.useMemo(() => { const map = new Map(); for (const w of wires) { if (!w.bb) continue; const arr = map.get(w.start.componentId); if (arr) arr.push(w.start.pinName); else map.set(w.start.componentId, [w.start.pinName]); } return map; }, [wires]); // Render component using dynamic renderer const renderComponent = (component: any) => { // SPICE probes are React components, not web components — render them // directly and let PinOverlay read the pinInfo we attach in the wrapper. if (component.metadataId === 'instr-voltmeter' || component.metadataId === 'instr-ammeter') { const isSelected = selectedComponentId === component.id; const isHovered = hoveredComponentId === component.id; // Suppress pin overlays whenever a modal-style UI is open over the // canvas — they'd just visually conflict with the dialog's controls. const dialogOpen = showPropertyDialog || customChipComponentId !== null || sensorControlComponentId !== null; // On touch devices the pin picker dialog (PinPickerDialog) is the // primary way to pick pins, so the tiny overlay squares are hidden — // they're hard to hit with a finger anyway. Desktop shows overlays on // hover / while wiring only — selection alone doesn't light them up. const showPinsForComponent = !dialogOpen && !isTouchDevice && (wireInProgress || isHovered); return (
setHoveredComponentId(component.id)} onMouseLeave={() => setHoveredComponentId((curr) => (curr === component.id ? null : curr)) } // Zero-size positioned wrapper: children keep their absolute canvas // coords, but body + pins now share ONE stacking context, so this // component's pins can never paint above a component covering it. // pointerEvents re-enables hit-testing under .components-area's // pointer-events: none. style={{ position: 'absolute', left: 0, top: 0, zIndex: isSelected ? 2 : 1, pointerEvents: 'auto', }} > handleComponentMouseDown(component.id, e)} /> {!interactionRunning && ( )}
); } const metadata = registry.getById(component.metadataId); if (!metadata) { console.warn(`Metadata not found for component: ${component.metadataId}`); return null; } const isSelected = selectedComponentId === component.id; const isHovered = hoveredComponentId === component.id; const dialogOpen = showPropertyDialog || customChipComponentId !== null || sensorControlComponentId !== null; // Show pins only when relevant: while a wire is in progress (any pin is a // valid target) or while hovering the component — selection alone doesn't // light them up (a selected breadboard lit 170 squares permanently). // Hidden when a dialog is open. Hidden entirely on touch — there the // PinPickerDialog (tap component → list of pins) replaces the overlays. const showPinsForComponent = !dialogOpen && !isTouchDevice && (wireInProgress || isHovered); // Breadboards are the physical base of a circuit — everything plugs into // them — so they always sit at the very back: below boards (z 0), other // components (z 1/2) and wires (z 35). Selection doesn't raise them. const isBreadboard = String(component.metadataId).startsWith('breadboard'); // A large-bodied display seated on a breadboard physically sits ON the // board, so wires routed to holes UNDERNEATH it pass behind its body in // real life. On the flat canvas those bridge wires (segment strips whose // holes are literally under the display) otherwise paint OVER the digits // — the reported "los cables se ven superpuestos y casi ni se ven los // dígitos". Raise a seated display above the wire layer (z 35) so its // face occludes the wires crossing it, exactly as the real part would. // Scoped to occluding display bodies + only when actually seated, so // thin parts (resistors, LEDs) and free-floating displays are untouched. const isSeated = (seatedPinsByComponent.get(component.id)?.length ?? 0) > 0; const occludesWhenSeated = DISPLAY_BODY_METADATA_RE.test(String(component.metadataId)); const groupZIndex = isBreadboard ? -1 : isSeated && occludesWhenSeated ? isSelected ? 37 : 36 : isSelected ? 2 : 1; return (
setHoveredComponentId(component.id)} onMouseLeave={() => setHoveredComponentId((curr) => (curr === component.id ? null : curr)) } // Zero-size positioned wrapper: children keep their absolute canvas // coords, but body + pins now share ONE stacking context, so this // component's pins can never paint above a component covering it. // Selected components (z 2) still raise above unselected ones (z 1); // breadboards are pinned behind everything (z -1). // pointerEvents re-enables hit-testing under .components-area's // pointer-events: none. style={{ position: 'absolute', left: 0, top: 0, zIndex: groupZIndex, pointerEvents: 'auto', }} > { handleComponentMouseDown(component.id, e); }} /> {/* Green dots on pins plugged into a breadboard — always visible so "seated & connected" is legible without hovering. */} {/* Pin overlay for wire creation - hide while interacting/running */} {!interactionRunning && ( )}
); }; return (
{/* ESP32 crash notification */} {esp32CrashBoardId && (
ESP32 crash detected on board {esp32CrashBoardId} — cache error (IDF incompatibility)
)} {/* Main Canvas */}
{(() => { const headerJsx = (
{/* Status LED */} {/* Active board selector (multi-board) — hidden when no boards */} {boards.length > 0 ? ( ) : ( {t('editor.canvas.noBoard')} )} {/* Undo / Redo — canvas-scoped, mirrors the Ctrl+Z / Ctrl+Y handler in EditorPage. Tooltip surfaces the description of the command that would be applied. Disabled when the stack is exhausted in that direction. */} {/* Serial Monitor toggle */} {/* ESP32-CAM webcam stream toggle */} {activeBoard?.boardKind === 'esp32-cam' && ( )} {/* WiFi status indicator + IoT-gateway launcher (ESP32 + Pico W) */} {activeBoard && (isEsp32Kind(activeBoard.boardKind) || activeBoard.boardKind === 'pi-pico-w') && activeBoard.wifiStatus && (() => { // The Pico W virtual net assigns its IP deterministically when // the sketch connects; the bridge reports 'started' carrying the // IP. Treat that as got_ip so the badge matches the ESP32 (green, // clickable → the same /api/gateway proxy). const rawStatus = activeBoard.wifiStatus.status; const status = activeBoard.boardKind === 'pi-pico-w' && rawStatus === 'started' && activeBoard.wifiStatus.ip ? 'got_ip' : rawStatus; const hasIp = status === 'got_ip'; const sessionId = getTabSessionId(); const clientId = `${sessionId}::${activeBoard.id}`; const backendBase = (import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8001/api'; const gatewayUrl = `${backendBase}/gateway/${clientId}/`; const openGateway = () => { if (!hasIp) return; // A private overlay (velxio.dev) can install a synchronous // gate to keep the IoT gateway behind a paid plan. When it // returns true it has already handled the click (e.g. shown // an in-place upgrade modal), so we don't open the tab. // OSS builds have no hook → always open. const gate = (window as unknown as { __velxio_iot_gateway_open_gate__?: () => boolean; }).__velxio_iot_gateway_open_gate__; if (gate && gate()) return; // Pico W runs in THIS tab — a new tab would background and // freeze the emulation. Show the page in an in-app iframe. if (activeBoard.boardKind === 'pi-pico-w') { openDeviceGateway(gatewayUrl); } else { window.open(gatewayUrl, '_blank'); } }; return ( ); })()} {/* BLE status indicator (ESP32 boards only) */} {activeBoard && isEsp32Kind(activeBoard.boardKind) && activeBoard.bleStatus && ( )} {/* Oscilloscope toggle */}
{/* Zoom controls */}
{/* Component count */} {components.length} {/* Add Component */}
); return headerSlot ? createPortal(headerJsx, headerSlot) : headerJsx; })()}
{ isPanningRef.current = false; setPan({ ...panRef.current }); setDraggedComponentId(null); }} onContextMenu={(e) => { e.preventDefault(); if (wireInProgress) { cancelWireCreation(); return; } // Right-click on a wire → open its color / delete context menu. // Right-clicks on a board are handled by the board element's own // onContextMenu (which stops propagation), so this only fires over // empty canvas or a wire. Disabled while the simulation runs // (canvas is interact-only then). if (interactionRunning) 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) { setSelectedWire(wire.id); setWireContextMenu({ wireId: wire.id, x: e.clientX, y: e.clientY }); } }} 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; } // Mouse-up already selected a wire under this click (breadboard // body case) — don't let this bubbled click toggle it back off. if (wireSelectJustHandledRef.current) { wireSelectJustHandledRef.current = false; return; } // While the simulation runs the canvas is interact-only: a click on // a button must press it (its own handler), not select the wire // underneath it for editing. if (interactionRunning) 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 || interactionRunning) 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) return; const seg = findSegmentNearPoint(wire, world.x, world.y, threshold); if (!seg) return; // Insert a draggable waypoint where the user double-clicked, // projected onto the segment so the wire stays orthogonal. const newWaypoints = insertWaypointAtSegment( wire.waypoints ?? [], seg, world.x, world.y, ); updateWire(wire.id, { waypoints: newWaypoints, autoRouted: false }); setSelectedWire(wire.id); }} style={{ cursor: isPanningRef.current ? 'grabbing' : wireInProgress ? 'crosshair' : hoveredWireId ? 'pointer' : 'default', }} > {/* Sensor Control Panel — shown when a sensor component is clicked during simulation. key={sensorControlComponentId} forces a fresh mount when the user clicks a different instance of the same sensor type (e.g. a second photoresistor); the slider state is local and would otherwise show the previously-clicked sensor's value until the user manually moved it. The sensorResetNonce suffix also remounts it on Reset, so the slider snaps back to the sensor's default value. */} {sensorControlComponentId && sensorControlMetadataId && (() => { const meta = registry.getById(sensorControlMetadataId); return ( { setSensorControlComponentId(null); setSensorControlMetadataId(null); }} /> ); })()} {/* Infinite world — pan+zoom applied here */}
{/* Wire Layer - Renders below all components */} {/* All boards on canvas */} {boards.map((board) => { const isHovered = hoveredBoardId === board.id; const isActive = board.id === activeBoardId; const dialogOpen = showPropertyDialog || customChipComponentId !== null || sensorControlComponentId !== null; // Pins show during wiring (every endpoint is a valid target) or // when hovering the board — NOT just for being the active board, // which lit up every pin permanently and cluttered the canvas. // Suppressed while a dialog is open. Hidden entirely on touch // since the PinPickerDialog (tap board to open list) replaces // the overlays — fingers can't reliably hit a 12px pin anyway. const showPins = !dialogOpen && !isTouchDevice && (wireInProgress || isHovered); return ( setHoveredBoardId(board.id)} onMouseLeave={() => setHoveredBoardId((curr) => (curr === board.id ? null : curr)) } onMouseDown={(e) => { setClickStartTime(Date.now()); setClickStartPos({ x: e.clientX, y: e.clientY }); const world = toWorld(e.clientX, e.clientY); setDraggedComponentId(`__board__:${board.id}`); setDragOffset({ x: world.x - board.x, y: world.y - board.y }); }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); setBoardContextMenu({ boardId: board.id, x: e.clientX, y: e.clientY }); }} onPinClick={handlePinClick} zoom={zoom} /> ); })} {/* Components using wokwi-elements */}
{registryLoaded && components.map(renderComponent)}
{/* Electrical simulation overlay (voltages / warnings). Voltage pills are hover-gated — see ElectricalOverlay. */}
{/* Wire creation mode banner — visible on both desktop and mobile */} {wireInProgress && ( cancelWireCreation()} /> )} {/* Floating action bar (top-center) for the current selection. Hidden while creating a wire (fights the wire-mode banner) and while the simulator is running (canvas is read-only). - WIRE selection shows on BOTH desktop and touch: it carries the color palette, which is otherwise only reachable on desktop via the 0-9 / c,l,m,p,y keyboard shortcuts (not discoverable). The bar is pinned top-center so it never covers pins near the wire. - COMPONENT selection stays touch-only — desktop already has the Delete key + the right-click rotate menu. */} {!wireInProgress && !interactionRunning && (() => { if (selectedWireId) { const wire = wires.find((w) => w.id === selectedWireId); return ( { if (!wire) return; recordUpdateWire(selectedWireId, { color: wire.color }, { color }); }} onDelete={() => { // Recorded so the delete is also undoable. recordRemoveWire(selectedWireId); setSelectedWire(null); }} onDeselect={() => setSelectedWire(null)} /> ); } if (isTouchDevice && selectedComponentId) { const c = components.find((x) => x.id === selectedComponentId); if (!c) return null; const meta = registry.getById(c.metadataId); return ( handleRotateComponent(selectedComponentId)} onDelete={() => { recordRemoveComponent(selectedComponentId); setSelectedComponentId(null); }} onDeselect={() => setSelectedComponentId(null)} /> ); } return null; })()} {/* Minimap — small overview of the world with a draggable viewport rectangle, anchored to the canvas-content bottom-right corner. Sits inside .canvas-content (not .canvas-world) so it stays fixed while the world pans / zooms. */} { panRef.current = p; setPan(p); }} panRef={panRef} zoomRef={zoomRef} components={components} boards={boards} viewportRef={canvasRef} />
{/* Touch-friendly pin picker — used to pick a pin from a list when the user taps a component or board body (rather than poking a 12px overlay). Closes on backdrop tap or after a pin is chosen. */} {pinPicker && (() => { const id = pinPicker.targetId; const el = document.getElementById(id); const pins: Array<{ name: string; x: number; y: number; description?: string }> = el ? ((el as any).pinInfo ?? []) : []; let title = pinPicker.kind === 'board' ? 'Board' : 'Component'; if (pinPicker.kind === 'board') { const b = boards.find((x) => x.id === id); if (b) title = boardDisplayName(b); } else { const c = components.find((x) => x.id === id); const meta = c ? registry.getById(c.metadataId) : null; if (meta) title = meta.name; } const subtitle = wireInProgress ? 'Tap a pin to connect' : 'Tap a pin to start a wire'; // Rotate is only meaningful for components (boards have no rotation). const handlePickerRotate = pinPicker.kind === 'component' ? () => { handleRotateComponent(id); } : undefined; // Delete handlers route through the existing flows: components use // removeComponent() directly; boards use the confirmation dialog // that's already wired for the right-click "Remove board" item. const handlePickerDelete = () => { if (pinPicker.kind === 'board') { setPinPicker(null); setBoardToRemove(id); } else { setPinPicker(null); recordRemoveComponent(id); setSelectedComponentId(null); } }; return ( setPinPicker(null)} onPinSelect={(targetId, pinName) => { const pin = pins.find((p) => p.name === pinName); if (!pin) { setPinPicker(null); return; } // Resolve world coords the SAME way wires + the pin overlay do: // calculatePinPosition rotates the pin around the wrapper centre // for the component's rotation. The old getBoundingClientRect path // added the unrotated pin offset to the ROTATED bounding-box corner, // landing the wire start ~70-100px off on a rotated part (issue #231). let worldX: number; let worldY: number; if (pinPicker.kind === 'board') { const b = boards.find((x) => x.id === targetId); const pos = calculatePinPosition(targetId, pinName, b?.x ?? 0, b?.y ?? 0, 0); worldX = pos?.x ?? (b?.x ?? 0) + pin.x; worldY = pos?.y ?? (b?.y ?? 0) + pin.y; } else { const c = components.find((x) => x.id === targetId); const rot = c ? Number(c.properties?.rotation) || 0 : 0; const pos = calculatePinPosition(targetId, pinName, (c?.x ?? 0) + 6, (c?.y ?? 0) + 6, rot); worldX = pos?.x ?? (c?.x ?? 0) + pin.x; worldY = pos?.y ?? (c?.y ?? 0) + pin.y; } setPinPicker(null); handlePinClick(targetId, pinName, worldX, worldY); }} /> ); })()} {/* 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) => { recordRemoveComponent(id); setShowPropertyDialog(false); }} onPropertyChange={(id, propName, value) => { const comp = components.find((c) => c.id === id); if (comp) { const prevValue = comp.properties[propName]; updateComponent(id, { properties: { ...comp.properties, [propName]: value }, }); // Property panel is the canonical UI for property edits — record // each change so Ctrl+Z reverts the value (without re-running the // raw mutation, which already happened). if (prevValue !== value) { recordSetProperty(id, propName, prevValue, value); } } }} onPinSelect={(id, pinName) => { // Resolve world coords the SAME way wires + the pin overlay do, // via calculatePinPosition, which rotates the pin around the // wrapper centre for the component's rotation. The old // getBoundingClientRect path added the unrotated pin offset to the // ROTATED bounding-box corner, so on a rotated part the wire start // landed ~70-100px away from the pin (issue #231). const pin = (pinInfo || []).find((p: { name: string }) => p.name === pinName); const c = components.find((x) => x.id === id); if (!c || !pin) return; const rot = Number(c.properties?.rotation) || 0; const pos = calculatePinPosition(id, pinName, c.x + 6, c.y + 6, rot); const worldX = pos?.x ?? c.x + pin.x; const worldY = pos?.y ?? c.y + pin.y; setShowPropertyDialog(false); handlePinClick(id, pinName, worldX, worldY); }} /> ); })()} {/* Custom Chip Designer Dialog */} {customChipComponentId && (() => { const comp = components.find((c) => c.id === customChipComponentId); if (!comp) return null; const props = comp.properties as Record; return ( ) ?? {}, }} onClose={() => setCustomChipComponentId(null)} onSave={(data) => { updateComponent(customChipComponentId, { properties: { ...comp.properties, ...data }, } as any); setCustomChipComponentId(null); // Force the chip's pin layout to re-render after the save. recalculateAllWirePositions(); }} /> ); })()} {/* Component Picker Modal */} setShowComponentPicker(false)} onSelectComponent={handleSelectComponent} onSelectBoard={(kind: BoardKind) => { // Pro gate: STM32 + Raspberry Pi emulation is paid-only on the web. // The overlay's gate returns 'block' for non-paid web users; show the // upgrade prompt and skip the add. OSS / desktop / paid -> 'allow'. if (boardGateDecision(kind) === 'block') { triggerProUpgradePrompt(proBoardFeatureName(kind)); return; } trackSelectBoard(kind); 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); // file group is created inside addBoard void newBoardId; }} /> {/* Board right-click context menu */} {wireContextMenu && (() => { const wire = wires.find((w) => w.id === wireContextMenu.wireId); if (!wire) return null; return ( <>
setWireContextMenu(null)} onContextMenu={(e) => { e.preventDefault(); setWireContextMenu(null); }} />
{t('editor.selectionBar.changeColor')}
{Object.values(WIRE_KEY_COLORS).map((color) => (
); })()} {boardContextMenu && (() => { const board = boards.find((b) => b.id === boardContextMenu.boardId); const label = board ? boardDisplayName(board) : 'Board'; const connectedWires = wires.filter( (w) => w.start.componentId === boardContextMenu.boardId || w.end.componentId === boardContextMenu.boardId, ).length; const supportsOptions = board ? isEsp32Family(board.boardKind) : false; return ( <>
setBoardContextMenu(null)} onContextMenu={(e) => { e.preventDefault(); setBoardContextMenu(null); }} />
{label}
{/* Hardware flash — only useful inside Tauri (web can't talk to USB serial without WebSerial). Hidden in web so the item doesn't tease a feature that won't work until the WebSerial track lands. */} {isTauriRuntime && ( )}
); })()} {/* Hardware flash modal — opens from board context menu when the user has compiled the sketch + clicks "Flash to real board". Only present in Tauri (web hides the menu item). */} {flashModalFor && (() => { const b = boards.find((x) => x.id === flashModalFor); if (!b) return null; const fqbn = BOARD_KIND_FQBN[b.boardKind]; if (!fqbn) { // The board kind has no arduino-cli FQBN (e.g. some // virtual boards or chips). Auto-close — surface a toast // via the existing menu-event system if/when that exists. console.warn('[flash] no FQBN for board kind', b.boardKind); setFlashModalFor(null); return null; } return ( setFlashModalFor(null)} /> ); })()} {/* Board Options modal (ESP32 only) */} {boardOptionsModalFor && (() => { const b = boards.find((x) => x.id === boardOptionsModalFor); if (!b) return null; return ( setBoardOptionsModalFor(null)} onApply={(opts) => updateBoard(b.id, { boardOptions: opts })} onSpiffsChange={(files) => updateBoard(b.id, { spiffsFiles: files })} /> ); })()} {/* Board removal confirmation dialog */} {boardToRemove && (() => { const board = boards.find((b) => b.id === boardToRemove); const label = board ? boardDisplayName(board) : t('editor.canvas.removeConfirm.boardFallback'); const connectedWires = wires.filter( (w) => w.start.componentId === boardToRemove || w.end.componentId === boardToRemove, ).length; return (

{t('editor.canvas.removeConfirm.title', { label })}

{connectedWires > 0 ? t('editor.canvas.removeConfirm.bodyWithWires', { count: connectedWires }) : t('editor.canvas.removeConfirm.body')}

); })()}
); };