velxio/frontend/src/components/simulator/SimulatorCanvas.tsx

2767 lines
110 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useSimulatorStore, getEsp32Bridge } from '../../store/useSimulatorStore';
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 { PartSimulationRegistry } from '../../simulation/parts';
import { PROPERTY_CHANGE_EVENT, type PropertyChangeDetail } from '../../simulation/parts/partUtils';
import { isSpiceMapped } from '../../simulation/spice/componentToSpice';
import { PinOverlay } from './PinOverlay';
import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping';
import { autoWireColor, WIRE_KEY_COLORS } from '../../utils/wireUtils';
import {
findWireNearPoint,
findSegmentNearPoint,
getRenderedPoints,
getRenderedSegments,
moveSegment,
renderedToWaypoints,
renderedPointsToPath,
simplifyOrthogonalPath,
insertWaypointAtSegment,
collectAlignmentTargets,
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_LABELS } from '../../types/board';
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 './SimulatorCanvas.css';
/** World-units of tolerance for alignment snap (scales with zoom). */
const ALIGN_SNAP_PX = 6;
/** 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;
/** 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,
pinManager,
initSimulator,
updateComponentState,
removeBoard,
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;
// 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);
// 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<string | null>(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<string | null>(null);
const [hoveredBoardId, setHoveredBoardId] = useState<string | null>(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<string | null>(null);
/** When non-null, the Custom Chip designer dialog is open for this component. */
const [customChipComponentId, setCustomChipComponentId] = useState<string | null>(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<string | null>(null);
const [sensorControlMetadataId, setSensorControlMetadataId] = useState<string | null>(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<Record<string, boolean>>({});
// Board context menu (right-click)
const [boardContextMenu, setBoardContextMenu] = useState<{
boardId: string;
x: number;
y: number;
} | null>(null);
// Board removal confirmation dialog
const [boardToRemove, setBoardToRemove] = useState<string | null>(null);
// Click vs drag detection
const [clickStartTime, setClickStartTime] = useState<number>(0);
const [clickStartPos, setClickStartPos] = useState({ x: 0, y: 0 });
// Component dragging state
const [draggedComponentId, setDraggedComponentId] = useState<string | null>(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<HTMLDivElement>(null);
// Pan & zoom state
const [pan, setPan] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
// Use refs during active pan to avoid setState lag
const isPanningRef = useRef(false);
const panStartRef = useRef({ mouseX: 0, mouseY: 0, panX: 0, panY: 0 });
const panRef = useRef({ x: 0, y: 0 });
const zoomRef = useRef(1);
// Refs that mirror state/props for use inside touch event closures
// (touch listeners are added imperatively and can't access current React state)
const runningRef = useRef(running);
runningRef.current = running;
const componentsRef = useRef(components);
componentsRef.current = components;
const boardPositionRef = useRef(boardPosition);
boardPositionRef.current = boardPosition;
const boardsRef = useRef(boards);
boardsRef.current = boards;
// Wire interaction state (canvas-level hit detection — bypasses SVG pointer-events issues)
const [hoveredWireId, setHoveredWireId] = useState<string | null>(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<AlignmentGuide[]>([]);
/** Set to true during mouseup if a segment/waypoint drag committed, so onClick can skip selection. */
const segmentDragJustCommittedRef = useRef(false);
const wiresRef = useRef(wires);
wiresRef.current = wires;
// Compute midpoint handles for the selected wire's segments
const segmentHandles = React.useMemo<SegmentHandle[]>(() => {
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<WaypointHandle[]>(() => {
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<string | null>(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);
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<ReturnType<typeof setTimeout> | 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<PropertyChangeDetail>).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);
}, []);
// Auto-start/stop Pi bridges when simulation state changes
const startBoard = useSimulatorStore((s) => s.startBoard);
const stopBoard = useSimulatorStore((s) => s.stopBoard);
useEffect(() => {
const remoteBoards = boards.filter(
(b) =>
b.boardKind === 'raspberry-pi-3' ||
b.boardKind === 'esp32' ||
b.boardKind === 'esp32-s3' ||
b.boardKind === 'esp32-c3',
);
remoteBoards.forEach((b) => {
if (running && !b.running) startBoard(b.id);
else if (!running && b.running) stopBoard(b.id);
});
}, [running, boards, startBoard, stopBoard]);
// Attach wheel listener as non-passive so preventDefault() works
useEffect(() => {
const el = canvasRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = el.getBoundingClientRect();
const factor = e.deltaY < 0 ? 1.1 : 0.9;
const newZoom = Math.min(5, Math.max(0.1, zoomRef.current * factor));
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const worldX = (mx - panRef.current.x) / zoomRef.current;
const worldY = (my - panRef.current.y) / zoomRef.current;
const newPan = { x: mx - worldX * newZoom, y: my - worldY * newZoom };
zoomRef.current = newZoom;
panRef.current = newPan;
setZoom(newZoom);
setPan(newPan);
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
// Attach touch listeners as non-passive so preventDefault() works, enabling
// single-finger pan, 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;
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.
if (runningRef.current) {
const webComp = target?.closest('.web-component-container');
if (webComp) {
touchPassthroughRef.current = true;
// 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.)
if (touchPassthroughRef.current) 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 {
updateComponent(touchDraggedComponentIdRef.current!, {
x: world.x - touchDragOffsetRef.current.x,
y: world.y - touchDragOffsetRef.current.y,
} 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;
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) });
}
}
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, components stay interactive —
// only sensor panels may open; property editing is disabled.
const component = componentsRef.current.find((c) => c.id === touchId);
if (component) {
if (runningRef.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);
}
}
}
}
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 ──
if (isShortTap) {
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 });
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);
const hasSelfManagedVisuals = !!(logic && logic.attachEvents) || spiceOwned;
// 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;
if (isBoardComponent(otherEndpoint.componentId)) {
// Use the board's actual boardKind (not just its instance ID) so that
// a board whose ID is 'arduino-uno' but whose kind is 'esp32' gets the
// correct GPIO mapping ('GPIO4' → 4, not null).
const boardInstance = boards.find((b) => b.id === otherEndpoint.componentId);
const lookupKey = boardInstance ? boardInstance.boardKind : otherEndpoint.componentId;
const pin = boardPinToNumber(lookupKey, otherEndpoint.pinName);
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;
default:
ledPin = 13; // Pin 13 for Arduino Uno/Nano/Mega, ATtiny85, etc.
}
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'); // 0100
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 components and boards
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)
// also asks to delete the active board.
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);
} else if (activeBoardId) {
setBoardToRemove(activeBoardId);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedComponentId, recordRemoveComponent, activeBoardId]);
// 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<typeof recordAddComponent>[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;
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 {
updateComponent(draggedComponentId, {
x: world.x - dragOffset.x,
y: world.y - dragOffset.y,
} as any);
}
}
// Handle wire creation preview
if (wireInProgress) {
const world = toWorld(e.clientX, e.clientY);
updateWireInProgress(world.x, world.y);
return;
}
// Handle segment handle dragging
if (segmentDragRef.current) {
const world = toWorld(e.clientX, e.clientY);
const sd = segmentDragRef.current;
sd.isDragging = true;
const threshold = ALIGN_SNAP_PX / zoomRef.current;
const targets = collectAlignmentTargets(wiresRef.current, sd.wireId);
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);
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 stored = [
{ x: wire.start.x, y: wire.start.y },
...newWaypoints,
{ x: wire.end.x, y: wire.end.y },
];
const expanded: { x: number; y: number }[] = [stored[0]];
for (let i = 1; i < stored.length; i++) {
const prev = stored[i - 1];
const curr = stored[i];
if (prev.x !== curr.x && prev.y !== curr.y) {
expanded.push({ x: curr.x, y: prev.y });
}
expanded.push(curr);
}
const overridePath = renderedPointsToPath(simplifyOrthogonalPath(expanded));
setSegmentDragPreview({ wireId: wd.wireId, overridePath });
}
return;
}
// Wire hover detection (when not dragging anything)
if (!draggedComponentId) {
const world = toWorld(e.clientX, e.clientY);
const threshold = 8 / zoomRef.current;
const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold);
setHoveredWireId(wire ? wire.id : null);
}
};
const handleCanvasMouseUp = (e: React.MouseEvent) => {
// Finish panning — commit ref value to state so React knows the final pan
if (isPanningRef.current) {
isPanningRef.current = false;
setPan({ ...panRef.current });
return;
}
// Commit segment handle drag
if (segmentDragRef.current) {
const sd = segmentDragRef.current;
if (sd.isDragging) {
segmentDragJustCommittedRef.current = true;
const world = toWorld(e.clientX, e.clientY);
const threshold = ALIGN_SNAP_PX / zoomRef.current;
const targets = collectAlignmentTargets(wiresRef.current, sd.wireId);
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) });
}
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);
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 stored = [
{ x: wire.start.x, y: wire.start.y },
...newWaypoints,
{ x: wire.end.x, y: wire.end.y },
];
const expanded: { x: number; y: number }[] = [stored[0]];
for (let i = 1; i < stored.length; i++) {
const prev = stored[i - 1];
const curr = stored[i];
if (prev.x !== curr.x && prev.y !== curr.y) {
expanded.push({ x: curr.x, y: prev.y });
}
expanded.push(curr);
}
updateWire(wd.wireId, { waypoints: renderedToWaypoints(expanded) });
}
}
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 (running) {
// During simulation 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.
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 {
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__')
) {
const moved = 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) => {
if (e.button === 1 || e.button === 2) {
e.preventDefault();
isPanningRef.current = true;
panStartRef.current = {
mouseX: e.clientX,
mouseY: e.clientY,
panX: panRef.current.x,
panY: panRef.current.y,
};
}
};
// Handle mousedown on a segment handle circle (called from WireLayer)
const handleHandleMouseDown = useCallback(
(e: React.MouseEvent, segIndex: number) => {
e.stopPropagation();
e.preventDefault();
if (!selectedWireId) return;
const wire = wiresRef.current.find((w) => w.id === selectedWireId);
if (!wire) return;
const segments = getRenderedSegments(wire);
const seg = segments[segIndex];
if (!seg) return;
const expandedPts = getRenderedPoints(wire);
segmentDragRef.current = {
wireId: wire.id,
segIndex,
axis: seg.axis,
renderedPts: expandedPts,
isDragging: false,
};
},
[selectedWireId],
);
// Handle touchstart on a segment handle circle (mobile wire editing)
const handleHandleTouchStart = useCallback(
(e: React.TouchEvent, segIndex: number) => {
e.stopPropagation();
if (!selectedWireId) return;
const wire = wiresRef.current.find((w) => w.id === selectedWireId);
if (!wire) return;
const segments = getRenderedSegments(wire);
const seg = segments[segIndex];
if (!seg) return;
const expandedPts = getRenderedPoints(wire);
segmentDragRef.current = {
wireId: wire.id,
segIndex,
axis: seg.axis,
renderedPts: expandedPts,
isDragging: false,
};
},
[selectedWireId],
);
// Handle mousedown on a waypoint (bend-point) handle: free 2D drag
const handleWaypointMouseDown = useCallback(
(e: React.MouseEvent, waypointIndex: number) => {
e.stopPropagation();
e.preventDefault();
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 (!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) => {
// Close property dialog when starting wire creation
if (showPropertyDialog) {
setShowPropertyDialog(false);
}
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: auto-detect color from pin name
startWireCreation({ componentId, pinName, x, y }, autoWireColor(pinName));
}
};
// Keyboard handlers for wires
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Escape → cancel in-progress wire
if (e.key === 'Escape' && wireInProgress) {
cancelWireCreation();
return;
}
// Delete / Backspace → remove selected wire (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<typeof setTimeout>[] = [];
// 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<HTMLElement>(`[data-board-id="${b.id}"]`);
if (el) targets.push(el);
});
componentsRef.current.forEach((c) => {
const el = canvas.querySelector<HTMLElement>(`[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]);
// 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 still uses overlays
// (hover/select shows them so the user can click with a mouse).
const showPinsForComponent =
!dialogOpen && !isTouchDevice && (wireInProgress || isSelected || isHovered);
return (
<React.Fragment key={component.id}>
<InstrumentComponent
id={component.id}
metadataId={component.metadataId}
x={component.x}
y={component.y}
isSelected={isSelected}
onMouseDown={(e) => handleComponentMouseDown(component.id, e)}
/>
{!running && (
<PinOverlay
componentId={component.id}
componentX={component.x}
componentY={component.y}
onPinClick={handlePinClick}
showPins={showPinsForComponent}
zoom={zoom}
wrapperOffsetX={0}
wrapperOffsetY={0}
/>
)}
</React.Fragment>
);
}
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), when this component is selected, or while hovering it.
// 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 || isSelected || isHovered);
return (
<React.Fragment key={component.id}>
<DynamicComponent
id={component.id}
metadata={metadata}
properties={component.properties}
x={component.x}
y={component.y}
isSelected={isSelected}
onMouseDown={(e) => {
handleComponentMouseDown(component.id, e);
}}
onMouseEnter={() => setHoveredComponentId(component.id)}
onMouseLeave={() =>
setHoveredComponentId((curr) => (curr === component.id ? null : curr))
}
/>
{/* Pin overlay for wire creation - hide when running */}
{!running && (
<PinOverlay
componentId={component.id}
componentX={component.x}
componentY={component.y}
onPinClick={handlePinClick}
showPins={showPinsForComponent}
zoom={zoom}
/>
)}
</React.Fragment>
);
};
return (
<div className="simulator-canvas-container">
{/* ESP32 crash notification */}
{esp32CrashBoardId && (
<div
style={{
position: 'absolute',
top: 8,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1000,
background: '#c0392b',
color: '#fff',
padding: '8px 16px',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
gap: 12,
fontSize: 13,
boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
}}
>
<span>
ESP32 crash detected on board <strong>{esp32CrashBoardId}</strong> cache error (IDF
incompatibility)
</span>
<button
onClick={dismissEsp32Crash}
style={{
background: 'transparent',
border: '1px solid rgba(255,255,255,0.6)',
color: '#fff',
borderRadius: 4,
padding: '2px 8px',
cursor: 'pointer',
}}
>
{t('editor.canvas.dismiss')}
</button>
</div>
)}
{/* Main Canvas */}
<div className="simulator-canvas">
{(() => {
const headerJsx = (
<div className={`canvas-header${headerSlot ? ' canvas-header--portaled' : ''}`}>
<div className="canvas-header-left">
{/* Status LED */}
<span
className={`status-dot ${running ? 'running' : 'stopped'}`}
title={running ? t('editor.canvas.status.running') : t('editor.canvas.status.stopped')}
/>
{/* Active board selector (multi-board) — hidden when no boards */}
{boards.length > 0 ? (
<select
className="board-selector"
value={activeBoardId ?? ''}
onChange={(e) => useSimulatorStore.getState().setActiveBoardId(e.target.value)}
disabled={running}
title={t('editor.canvas.activeBoard')}
>
{boards.map((b) => (
<option key={b.id} value={b.id}>
{BOARD_KIND_LABELS[b.boardKind] ?? b.id}
</option>
))}
</select>
) : (
<span
className="board-selector"
style={{ opacity: 0.55, fontStyle: 'italic', cursor: 'default' }}
title={t('editor.canvas.noBoardHint')}
>
{t('editor.canvas.noBoard')}
</span>
)}
{/* 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. */}
<button
onClick={() => undo()}
disabled={historyIndex < 0}
className="canvas-icon-btn"
title={
historyIndex >= 0
? t('editor.canvas.undo.title', { description: history[historyIndex].description })
: t('editor.canvas.undo.empty')
}
aria-label={t('editor.canvas.undo.label')}
>
<Undo2 size={16} strokeWidth={2} aria-hidden="true" />
</button>
<button
onClick={() => redo()}
disabled={historyIndex >= history.length - 1}
className="canvas-icon-btn"
title={
historyIndex < history.length - 1
? t('editor.canvas.redo.title', { description: history[historyIndex + 1].description })
: t('editor.canvas.redo.empty')
}
aria-label={t('editor.canvas.redo.label')}
>
<Redo2 size={16} strokeWidth={2} aria-hidden="true" />
</button>
{/* Serial Monitor toggle */}
<button
onClick={() => {
toggleSerialMonitor();
trackToggleSerialMonitor(!serialMonitorOpen);
}}
className={`canvas-serial-btn${serialMonitorOpen ? ' canvas-serial-btn-active' : ''}`}
title={t('editor.canvas.toggleSerialMonitor')}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
{t('editor.canvas.serial')}
</button>
{/* ESP32-CAM webcam stream toggle */}
{activeBoard?.boardKind === 'esp32-cam' && (
<CameraToggle boardId={activeBoard.id} />
)}
{/* WiFi status indicator (ESP32 boards only) */}
{activeBoard &&
isEsp32Kind(activeBoard.boardKind) &&
activeBoard.wifiStatus &&
(() => {
const status = activeBoard.wifiStatus.status;
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}/`;
return (
<span
className={`canvas-wifi-badge canvas-wifi-${status}${hasIp ? ' canvas-wifi-clickable' : ''}`}
onClick={() => hasIp && window.open(gatewayUrl, '_blank')}
title={
hasIp
? `WiFi: ${activeBoard.wifiStatus.ssid ?? 'Velxio-GUEST'} — IP: ${activeBoard.wifiStatus.ip}\nClick to open IoT Gateway ↗`
: status === 'connected'
? `WiFi: ${activeBoard.wifiStatus.ssid ?? 'Velxio-GUEST'} — Connecting...`
: status === 'initializing'
? 'WiFi: Initializing...'
: 'WiFi: Disconnected'
}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
<circle cx="12" cy="20" r="1" />
</svg>
</span>
);
})()}
{/* BLE status indicator (ESP32 boards only) */}
{activeBoard && isEsp32Kind(activeBoard.boardKind) && activeBoard.bleStatus && (
<span
className={`canvas-ble-badge canvas-ble-${activeBoard.bleStatus.status}`}
title={
activeBoard.bleStatus.status === 'advertising'
? 'BLE: Advertising'
: 'BLE: Initialized'
}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6.5 6.5 17.5 17.5 12 23 12 1 17.5 6.5 6.5 17.5" />
</svg>
</span>
)}
{/* Oscilloscope toggle */}
<button
onClick={toggleOscilloscope}
className={`canvas-serial-btn${oscilloscopeOpen ? ' canvas-serial-btn-active' : ''}`}
title={t('editor.canvas.toggleScope')}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="2 14 6 8 10 14 14 6 18 14 22 10" />
</svg>
{t('editor.canvas.scope')}
</button>
</div>
<div className="canvas-header-right">
{/* Zoom controls */}
<div className="zoom-controls">
<button
className="zoom-btn"
onClick={() =>
handleWheel({
deltaY: 100,
clientX: 0,
clientY: 0,
preventDefault: () => {},
} as any)
}
title={t('editor.canvas.zoomOut')}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<button
className="zoom-level"
onClick={handleResetView}
title={t('editor.canvas.resetView')}
>
{Math.round(zoom * 100)}%
</button>
<button
className="zoom-btn"
onClick={() =>
handleWheel({
deltaY: -100,
clientX: 0,
clientY: 0,
preventDefault: () => {},
} as any)
}
title={t('editor.canvas.zoomIn')}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
{/* Component count */}
<span
className="component-count"
title={t('editor.canvas.componentCount', { count: components.length })}
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="7" width="20" height="14" rx="2" />
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
</svg>
{components.length}
</span>
{/* Add Component */}
<button
className="add-component-btn"
onClick={() => setShowComponentPicker(true)}
title={t('editor.canvas.addComponentTitle')}
disabled={running}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{t('editor.canvas.add')}
</button>
</div>
</div>
);
return headerSlot ? createPortal(headerJsx, headerSlot) : headerJsx;
})()}
<div
ref={canvasRef}
className="canvas-content"
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp}
onMouseLeave={() => {
isPanningRef.current = false;
setPan({ ...panRef.current });
setDraggedComponentId(null);
}}
onContextMenu={(e) => {
e.preventDefault();
if (wireInProgress) cancelWireCreation();
}}
onClick={(e) => {
if (wireInProgress) {
const world = toWorld(e.clientX, e.clientY);
addWireWaypoint(world.x, world.y);
return;
}
// If a segment handle drag just finished, don't also select
if (segmentDragJustCommittedRef.current) {
segmentDragJustCommittedRef.current = false;
return;
}
// Wire selection via canvas-level hit detection
const world = toWorld(e.clientX, e.clientY);
const threshold = 8 / zoomRef.current;
const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold);
if (wire) {
setSelectedWire(selectedWireId === wire.id ? null : wire.id);
} else {
setSelectedWire(null);
setSelectedComponentId(null);
}
}}
onDoubleClick={(e) => {
if (wireInProgress) return;
const world = toWorld(e.clientX, e.clientY);
const threshold = 8 / zoomRef.current;
const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold);
if (!wire) 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 });
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 */}
{sensorControlComponentId &&
sensorControlMetadataId &&
(() => {
const meta = registry.getById(sensorControlMetadataId);
return (
<SensorControlPanel
componentId={sensorControlComponentId}
metadataId={sensorControlMetadataId}
sensorName={meta?.name ?? sensorControlMetadataId}
onClose={() => {
setSensorControlComponentId(null);
setSensorControlMetadataId(null);
}}
/>
);
})()}
{/* Infinite world — pan+zoom applied here */}
<div
className="canvas-world"
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
>
{/* Wire Layer - Renders below all components */}
<WireLayer
hoveredWireId={hoveredWireId}
segmentDragPreview={segmentDragPreview}
segmentHandles={segmentHandles}
waypointHandles={waypointHandles}
alignmentGuides={alignmentGuides}
onHandleMouseDown={handleHandleMouseDown}
onHandleTouchStart={handleHandleTouchStart}
onWaypointMouseDown={handleWaypointMouseDown}
onWaypointTouchStart={handleWaypointTouchStart}
/>
{/* 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),
// when hovering the board, or when it's the active board.
// 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 || isActive);
return (
<BoardOnCanvas
key={board.id}
board={board}
running={running}
isActive={isActive}
showPins={showPins}
led13={Boolean(boardLedStates[board.id])}
onMouseEnter={() => 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 */}
<div className="components-area">
{registryLoaded && components.map(renderComponent)}
</div>
{/* Electrical simulation overlay (voltages / warnings) */}
<ElectricalOverlay />
</div>
{/* Wire creation mode banner — visible on both desktop and mobile */}
{wireInProgress && (
<WireModeBanner
message="Tap a pin to connect — tap canvas for waypoints"
onCancel={() => cancelWireCreation()}
/>
)}
{/* Floating action bar for the current selection — primary delete UI
for touch devices (no Delete key, no right-click). Hidden while
creating a wire so it doesn't fight the wire-mode banner. */}
{!wireInProgress &&
(() => {
if (selectedWireId) {
const wire = wires.find((w) => w.id === selectedWireId);
return (
<SelectionActionBar
kind="wire"
label="Wire"
currentColor={wire?.color}
onColorChange={(color) => {
if (!wire) return;
recordUpdateWire(selectedWireId, { color: wire.color }, { color });
}}
onDelete={() => {
// Recorded so the touch / mobile delete is also undoable.
recordRemoveWire(selectedWireId);
setSelectedWire(null);
}}
onDeselect={() => setSelectedWire(null)}
/>
);
}
if (selectedComponentId) {
const c = components.find((x) => x.id === selectedComponentId);
if (!c) return null;
const meta = registry.getById(c.metadataId);
return (
<SelectionActionBar
kind="component"
label={meta?.name ?? 'Component'}
canRotate
onRotate={() => handleRotateComponent(selectedComponentId)}
onDelete={() => {
recordRemoveComponent(selectedComponentId);
setSelectedComponentId(null);
}}
onDeselect={() => setSelectedComponentId(null)}
/>
);
}
return null;
})()}
</div>
</div>
{/* 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 = BOARD_KIND_LABELS[b.boardKind] ?? b.boardKind;
} 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 (
<PinPickerDialog
targetId={id}
title={title}
subtitle={subtitle}
pins={pins}
onRotate={handlePickerRotate}
onDelete={handlePickerDelete}
onClose={() => setPinPicker(null)}
onPinSelect={(targetId, pinName) => {
const pin = pins.find((p) => p.name === pinName);
if (!pin) {
setPinPicker(null);
return;
}
// Resolve world coords from the rendered element rect — this
// accounts for wrapper offsets, rotation, and current zoom.
const compEl = document.getElementById(targetId);
const canvasRect = canvasRef.current?.getBoundingClientRect();
const compRect = compEl?.getBoundingClientRect();
let worldX: number;
let worldY: number;
if (canvasRect && compRect) {
const screenX = compRect.left + pin.x * zoomRef.current;
const screenY = compRect.top + pin.y * zoomRef.current;
const w = toWorld(screenX, screenY);
worldX = w.x;
worldY = w.y;
} else {
// Fallback: approximate from the stored x/y of the target.
if (pinPicker.kind === 'board') {
const b = boards.find((x) => x.id === targetId);
worldX = (b?.x ?? 0) + pin.x;
worldY = (b?.y ?? 0) + pin.y;
} else {
const c = components.find((x) => x.id === targetId);
worldX = (c?.x ?? 0) + pin.x;
worldY = (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 (
<ComponentPropertyDialog
componentId={propertyDialogComponentId}
componentMetadata={metadata}
componentProperties={component.properties}
position={propertyDialogPosition}
pinInfo={pinInfo || []}
wireInProgress={Boolean(wireInProgress)}
onClose={() => 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) => {
// Pick world coords from the live element rect when possible —
// it accounts for the wokwi-element wrapper offset and the
// current rotation. Falls back to component origin + pin
// offset if the rect can't be read.
const compEl = document.getElementById(id);
const pin = (pinInfo || []).find((p: { name: string }) => p.name === pinName);
const c = components.find((x) => x.id === id);
if (!c || !pin) return;
const canvasRect = canvasRef.current?.getBoundingClientRect();
const compRect = compEl?.getBoundingClientRect();
let worldX: number;
let worldY: number;
if (canvasRect && compRect) {
// Convert pin's CSS-pixel offset (relative to component) into
// world coords via the canvas pan/zoom transform.
const screenX = compRect.left + pin.x * zoomRef.current;
const screenY = compRect.top + pin.y * zoomRef.current;
const w = toWorld(screenX, screenY);
worldX = w.x;
worldY = w.y;
} else {
worldX = c.x + pin.x;
worldY = 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<string, unknown>;
return (
<CustomChipDialog
initial={{
chipName: String(props.chipName ?? 'My Chip'),
sourceC: String(props.sourceC ?? ''),
chipJson: String(props.chipJson ?? ''),
wasmBase64: String(props.wasmBase64 ?? ''),
attrs: (props.attrs as Record<string, number>) ?? {},
}}
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 */}
<ComponentPickerModal
isOpen={showComponentPicker}
onClose={() => setShowComponentPicker(false)}
onSelectComponent={handleSelectComponent}
onSelectBoard={(kind: BoardKind) => {
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 */}
{boardContextMenu &&
(() => {
const board = boards.find((b) => b.id === boardContextMenu.boardId);
const label = board ? BOARD_KIND_LABELS[board.boardKind] : 'Board';
const connectedWires = wires.filter(
(w) =>
w.start.componentId === boardContextMenu.boardId ||
w.end.componentId === boardContextMenu.boardId,
).length;
return (
<>
<div
style={{ position: 'fixed', inset: 0, zIndex: 9998 }}
onClick={() => setBoardContextMenu(null)}
onContextMenu={(e) => {
e.preventDefault();
setBoardContextMenu(null);
}}
/>
<div
style={{
position: 'fixed',
left: boardContextMenu.x,
top: boardContextMenu.y,
background: '#252526',
border: '1px solid #3c3c3c',
borderRadius: 6,
padding: '4px 0',
zIndex: 9999,
minWidth: 180,
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
fontSize: 13,
}}
>
<div
style={{
padding: '6px 14px',
color: '#888',
fontSize: 11,
borderBottom: '1px solid #3c3c3c',
marginBottom: 2,
}}
>
{label}
</div>
<button
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
width: '100%',
padding: '7px 14px',
background: 'none',
border: 'none',
color: '#e06c75',
cursor: 'pointer',
fontSize: 13,
textAlign: 'left',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2a2d2e';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'none';
}}
onClick={() => {
setBoardContextMenu(null);
setBoardToRemove(boardContextMenu.boardId);
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
{t('editor.canvas.removeBoard')}
{connectedWires > 0 && (
<span style={{ color: '#888', fontSize: 11 }}>
({t('editor.canvas.wireCount', { count: connectedWires })})
</span>
)}
</button>
</div>
</>
);
})()}
{/* Board removal confirmation dialog */}
{boardToRemove &&
(() => {
const board = boards.find((b) => b.id === boardToRemove);
const label = board ? BOARD_KIND_LABELS[board.boardKind] : t('editor.canvas.removeConfirm.boardFallback');
const connectedWires = wires.filter(
(w) => w.start.componentId === boardToRemove || w.end.componentId === boardToRemove,
).length;
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
zIndex: 10000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
background: '#1e1e1e',
border: '1px solid #3c3c3c',
borderRadius: 8,
padding: '20px 24px',
maxWidth: 380,
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
}}
>
<h3 style={{ margin: '0 0 10px', color: '#e0e0e0', fontSize: 15 }}>
{t('editor.canvas.removeConfirm.title', { label })}
</h3>
<p style={{ margin: '0 0 16px', color: '#999', fontSize: 13, lineHeight: 1.5 }}>
{connectedWires > 0
? t('editor.canvas.removeConfirm.bodyWithWires', { count: connectedWires })
: t('editor.canvas.removeConfirm.body')}
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={() => setBoardToRemove(null)}
style={{
padding: '6px 16px',
background: '#333',
border: '1px solid #555',
borderRadius: 4,
color: '#ccc',
cursor: 'pointer',
fontSize: 13,
}}
>
{t('editor.canvas.removeConfirm.cancel')}
</button>
<button
onClick={() => {
removeBoard(boardToRemove);
setBoardToRemove(null);
}}
style={{
padding: '6px 16px',
background: '#e06c75',
border: 'none',
borderRadius: 4,
color: '#fff',
cursor: 'pointer',
fontSize: 13,
}}
>
{t('editor.canvas.removeConfirm.remove')}
</button>
</div>
</div>
</div>
);
})()}
</div>
);
};