UNPKG

circuit-bricks

Version:

A modular, Lego-style SVG circuit component system for React (ALPHA - Not for production use)

1,761 lines (1,741 loc) 119 kB
"use strict"; //#region rolldown:runtime var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) { key = keys[i]; if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: ((k) => from[k]).bind(null, key), enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod)); //#endregion const react = __toESM(require("react")); const react_jsx_runtime = __toESM(require("react/jsx-runtime")); //#region src/registry/components/resistor.json var id$9 = "resistor"; var name$9 = "Resistor"; var category$9 = "passive"; var description$9 = "A passive two-terminal electrical component that implements electrical resistance"; var defaultWidth$9 = 80; var defaultHeight$9 = 30; var ports$9 = [{ "id": "left", "x": 0, "y": 15, "type": "inout" }, { "id": "right", "x": 80, "y": 15, "type": "inout" }]; var properties$9 = [{ "key": "resistance", "label": "Resistance", "type": "number", "unit": "Ω", "default": 1e3, "min": 0 }, { "key": "tolerance", "label": "Tolerance", "type": "number", "unit": "%", "default": 5, "min": 0, "max": 20 }]; var svgPath$9 = "M10,15 h10 l5,-10 l10,20 l10,-20 l10,20 l10,-20 l5,10 h10"; var resistor_default = { id: id$9, name: name$9, category: category$9, description: description$9, defaultWidth: defaultWidth$9, defaultHeight: defaultHeight$9, ports: ports$9, properties: properties$9, svgPath: svgPath$9 }; //#endregion //#region src/registry/components/capacitor.json var id$8 = "capacitor"; var name$8 = "Capacitor"; var category$8 = "passive"; var description$8 = "A passive two-terminal electrical component that stores electrical energy in an electric field"; var defaultWidth$8 = 60; var defaultHeight$8 = 40; var ports$8 = [{ "id": "left", "x": 0, "y": 20, "type": "inout" }, { "id": "right", "x": 60, "y": 20, "type": "inout" }]; var properties$8 = [{ "key": "capacitance", "label": "Capacitance", "type": "number", "unit": "μF", "default": 10, "min": 0 }, { "key": "voltageRating", "label": "Voltage Rating", "type": "number", "unit": "V", "default": 50, "min": 0 }]; var svgPath$8 = "M10,20 h15 M35,5 v30 M45,5 v30 M45,20 h15"; var capacitor_default = { id: id$8, name: name$8, category: category$8, description: description$8, defaultWidth: defaultWidth$8, defaultHeight: defaultHeight$8, ports: ports$8, properties: properties$8, svgPath: svgPath$8 }; //#endregion //#region src/registry/components/battery.json var id$7 = "battery"; var name$7 = "Battery"; var category$7 = "sources"; var description$7 = "A source of electrical energy that can be used to power circuits"; var defaultWidth$7 = 60; var defaultHeight$7 = 40; var ports$7 = [{ "id": "positive", "x": 60, "y": 20, "type": "output" }, { "id": "negative", "x": 0, "y": 20, "type": "output" }]; var properties$7 = [{ "key": "voltage", "label": "Voltage", "type": "number", "unit": "V", "default": 9, "min": 0 }, { "key": "internal_resistance", "label": "Internal Resistance", "type": "number", "unit": "Ω", "default": .1, "min": 0 }]; var svgPath$7 = "M10,20 h10 M20,5 v30 M30,10 v20 M40,5 v30 M40,20 h10"; var battery_default = { id: id$7, name: name$7, category: category$7, description: description$7, defaultWidth: defaultWidth$7, defaultHeight: defaultHeight$7, ports: ports$7, properties: properties$7, svgPath: svgPath$7 }; //#endregion //#region src/registry/components/ground.json var id$6 = "ground"; var name$6 = "Ground"; var category$6 = "basic"; var description$6 = "A reference point in an electrical circuit from which voltages are measured"; var defaultWidth$6 = 30; var defaultHeight$6 = 30; var ports$6 = [{ "id": "terminal", "x": 15, "y": 0, "type": "inout" }]; var properties$6 = []; var svgPath$6 = "M15,0 v15 M5,15 h20 M8,20 h14 M11,25 h8"; var ground_default = { id: id$6, name: name$6, category: category$6, description: description$6, defaultWidth: defaultWidth$6, defaultHeight: defaultHeight$6, ports: ports$6, properties: properties$6, svgPath: svgPath$6 }; //#endregion //#region src/registry/components/switch.json var id$5 = "switch"; var name$5 = "Switch"; var category$5 = "basic"; var description$5 = "A component that can be open or closed to control current flow"; var defaultWidth$5 = 60; var defaultHeight$5 = 30; var ports$5 = [{ "id": "left", "x": 0, "y": 15, "type": "inout" }, { "id": "right", "x": 60, "y": 15, "type": "inout" }]; var properties$5 = [{ "key": "state", "label": "State", "type": "select", "options": [{ "value": "open", "label": "Open" }, { "value": "closed", "label": "Closed" }], "default": "open" }]; var svgPath$5 = "M10,15 h10 M20,15 l20,-10 M40,15 h10"; var switch_default = { id: id$5, name: name$5, category: category$5, description: description$5, defaultWidth: defaultWidth$5, defaultHeight: defaultHeight$5, ports: ports$5, properties: properties$5, svgPath: svgPath$5 }; //#endregion //#region src/registry/components/voltage-source.json var id$4 = "voltage-source"; var name$4 = "Voltage Source"; var category$4 = "sources"; var description$4 = "An ideal voltage source with constant voltage"; var defaultWidth$4 = 50; var defaultHeight$4 = 50; var ports$4 = [{ "id": "positive", "x": 25, "y": 0, "type": "output" }, { "id": "negative", "x": 25, "y": 50, "type": "output" }]; var properties$4 = [ { "key": "voltage", "label": "Voltage", "type": "number", "unit": "V", "default": 5, "min": 0 }, { "key": "frequency", "label": "Frequency", "type": "number", "unit": "Hz", "default": 0, "min": 0 }, { "key": "sourceType", "label": "Source Type", "type": "select", "options": [{ "value": "dc", "label": "DC" }, { "value": "ac", "label": "AC" }], "default": "dc" } ]; var svgPath$4 = "M25,10 v10 M20,25 h10 M25,30 v10 M25,25 m-15,0 a15,15 0 1,0 30,0 a15,15 0 1,0 -30,0"; var voltage_source_default = { id: id$4, name: name$4, category: category$4, description: description$4, defaultWidth: defaultWidth$4, defaultHeight: defaultHeight$4, ports: ports$4, properties: properties$4, svgPath: svgPath$4 }; //#endregion //#region src/registry/components/diode.json var id$3 = "diode"; var name$3 = "Diode"; var category$3 = "semiconductor"; var description$3 = "A semiconductor device that allows current to flow in only one direction"; var defaultWidth$3 = 60; var defaultHeight$3 = 30; var ports$3 = [{ "id": "anode", "x": 0, "y": 15, "type": "inout" }, { "id": "cathode", "x": 60, "y": 15, "type": "inout" }]; var properties$3 = [{ "key": "forwardVoltage", "label": "Forward Voltage", "type": "number", "unit": "V", "default": .7, "min": 0 }, { "key": "maxCurrent", "label": "Max Current", "type": "number", "unit": "A", "default": 1, "min": 0 }]; var svgPath$3 = "M10,15 h10 M20,5 l0,20 L40,15 L20,5 M40,5 v20 M40,15 h10"; var diode_default = { id: id$3, name: name$3, category: category$3, description: description$3, defaultWidth: defaultWidth$3, defaultHeight: defaultHeight$3, ports: ports$3, properties: properties$3, svgPath: svgPath$3 }; //#endregion //#region src/registry/components/transistor-npn.json var id$2 = "transistor-npn"; var name$2 = "NPN Transistor"; var category$2 = "semiconductor"; var description$2 = "An NPN bipolar junction transistor for amplification and switching"; var defaultWidth$2 = 50; var defaultHeight$2 = 60; var ports$2 = [ { "id": "collector", "x": 25, "y": 0, "type": "inout" }, { "id": "base", "x": 0, "y": 30, "type": "input" }, { "id": "emitter", "x": 25, "y": 60, "type": "inout" } ]; var properties$2 = [{ "key": "gain", "label": "Current Gain (β)", "type": "number", "default": 100, "min": 1 }]; var svgPath$2 = "M25,0 v15 M25,45 v15 M0,30 h15 M15,15 v30 M15,30 l20,-10 l0,20 Z"; var transistor_npn_default = { id: id$2, name: name$2, category: category$2, description: description$2, defaultWidth: defaultWidth$2, defaultHeight: defaultHeight$2, ports: ports$2, properties: properties$2, svgPath: svgPath$2 }; //#endregion //#region src/registry/components/led.json var id$1 = "led"; var name$1 = "LED"; var category$1 = "semiconductor"; var description$1 = "Light-emitting diode that emits light when current flows through it"; var defaultWidth$1 = 60; var defaultHeight$1 = 40; var ports$1 = [{ "id": "anode", "x": 0, "y": 20, "type": "inout" }, { "id": "cathode", "x": 60, "y": 20, "type": "inout" }]; var properties$1 = [ { "key": "color", "label": "Color", "type": "color", "default": "#ff0000" }, { "key": "forwardVoltage", "label": "Forward Voltage", "type": "number", "unit": "V", "default": 1.8, "min": 0 }, { "key": "maxCurrent", "label": "Max Current", "type": "number", "unit": "mA", "default": 20, "min": 0 } ]; var svgPath$1 = "M10,20 h10 M20,5 l0,30 L40,20 L20,5 M40,5 v30 M40,20 h10 M25,5 l5,-5 l5,10 l5,-5 M25,10 l5,-5 l5,10 l5,-5"; var led_default = { id: id$1, name: name$1, category: category$1, description: description$1, defaultWidth: defaultWidth$1, defaultHeight: defaultHeight$1, ports: ports$1, properties: properties$1, svgPath: svgPath$1 }; //#endregion //#region src/registry/components/ic.json var id = "ic"; var name = "Integrated Circuit"; var category = "advanced"; var description = "A general-purpose integrated circuit with configurable pins"; var defaultWidth = 80; var defaultHeight = 100; var ports = [ { "id": "vcc", "x": 20, "y": 0, "type": "input", "label": "VCC" }, { "id": "gnd", "x": 60, "y": 100, "type": "input", "label": "GND" }, { "id": "in1", "x": 0, "y": 20, "type": "input", "label": "IN1" }, { "id": "in2", "x": 0, "y": 40, "type": "input", "label": "IN2" }, { "id": "in3", "x": 0, "y": 60, "type": "input", "label": "IN3" }, { "id": "in4", "x": 0, "y": 80, "type": "input", "label": "IN4" }, { "id": "out1", "x": 80, "y": 20, "type": "output", "label": "OUT1" }, { "id": "out2", "x": 80, "y": 40, "type": "output", "label": "OUT2" }, { "id": "out3", "x": 80, "y": 60, "type": "output", "label": "OUT3" }, { "id": "out4", "x": 80, "y": 80, "type": "output", "label": "OUT4" } ]; var properties = [{ "key": "name", "label": "IC Name", "type": "text", "default": "IC1" }, { "key": "type", "label": "IC Type", "type": "text", "default": "Custom IC" }]; var svgPath = "<svg width='80' height='100'><rect x='10' y='10' width='60' height='80' fill='none' stroke='black' stroke-width='2'/><text x='40' y='50' text-anchor='middle' font-family='monospace' font-size='12'>IC</text><circle cx='20' cy='10' r='3' fill='none' stroke='black'/></svg>"; var ic_default = { id, name, category, description, defaultWidth, defaultHeight, ports, properties, svgPath }; //#endregion //#region src/registry/components/index.ts const registerBuiltInComponents = () => { registerComponent(resistor_default); registerComponent(capacitor_default); registerComponent(ground_default); registerComponent(switch_default); registerComponent(battery_default); registerComponent(voltage_source_default); registerComponent(diode_default); registerComponent(transistor_npn_default); registerComponent(led_default); registerComponent(ic_default); }; var components_default = registerBuiltInComponents; //#endregion //#region src/registry/index.ts const componentRegistry = {}; /** * Register a component schema in the registry * * This function adds a new component schema to the registry or updates an existing one. * Custom components can be registered to extend the library with new circuit elements. * * @param {ComponentSchema} schema - The component schema to register * * @example * // Register a custom LED component * registerComponent({ * id: 'custom-led', * name: 'Custom LED', * category: 'output', * description: 'A light-emitting diode with customizable color', * defaultWidth: 30, * defaultHeight: 20, * ports: [ * { id: 'anode', x: 0, y: 10, type: 'input' }, * { id: 'cathode', x: 30, y: 10, type: 'output' } * ], * properties: [ * { id: 'color', name: 'Color', type: 'color', default: '#ff0000' } * ], * svgPath: `<circle cx="15" cy="10" r="8" fill="currentColor" />` * }); */ function registerComponent(schema) { if (componentRegistry[schema.id]) console.warn(`Component with ID ${schema.id} already exists in registry. Overwriting.`); componentRegistry[schema.id] = schema; } /** * Get a component schema from the registry by ID * * Retrieves a component's schema definition by its unique identifier. * Returns undefined if no component with the specified ID exists. * * @param {string} id - The unique identifier of the component * @returns {ComponentSchema | undefined} The component schema or undefined if not found * * @example * const resistorSchema = getComponentSchema('resistor'); * if (resistorSchema) { * console.log(`Resistor has ${resistorSchema.ports.length} ports`); * } */ function getComponentSchema(id$10) { return componentRegistry[id$10]; } /** * Get all component schemas in the registry */ function getAllComponents() { return Object.values(componentRegistry); } /** * Get component schemas by category */ function getComponentsByCategory(category$10) { return Object.values(componentRegistry).filter((schema) => schema.category === category$10); } components_default(); //#endregion //#region src/core/Port.tsx const Port = ({ componentId, port, selected = false, onClick }) => { const handleClick = (event) => { event.stopPropagation(); onClick?.(port.id, event); }; return /* @__PURE__ */ (0, react_jsx_runtime.jsx)( "circle", // Prevent triggering component click { cx: port.x, cy: port.y, r: 5, fill: selected ? "#4f8ef7" : "#555", stroke: "#000", strokeWidth: 1, "data-component-id": componentId, "data-port-id": port.id, "data-port-type": port.type, vectorEffect: "non-scaling-stroke", onClick: handleClick, style: { cursor: "pointer" } } ); }; var Port_default = Port; //#endregion //#region src/core/BaseComponent.tsx const BaseComponent = ({ schema, component, onClick, onMouseDown, onPortClick, selected = false }) => { const { id: id$10, position, size, rotation = 0 } = component; const width = size?.width || schema.defaultWidth; const height = size?.height || schema.defaultHeight; const transform = `translate(${position.x}, ${position.y})${rotation ? ` rotate(${rotation} ${width / 2} ${height / 2})` : ""}`; const outlineStyle = selected ? { stroke: "#4f8ef7", strokeWidth: 2, strokeDasharray: "none", fill: "none", pointerEvents: "none", vectorEffect: "non-scaling-stroke" } : void 0; return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { transform, onClick, onMouseDown, "data-component-id": id$10, "data-component-type": component.type, children: [ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", { width, height, viewBox: `0 0 ${schema.defaultWidth} ${schema.defaultHeight}`, preserveAspectRatio: "none", xmlns: "http://www.w3.org/2000/svg", children: schema.svgPath.startsWith("<svg") ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("g", { dangerouslySetInnerHTML: { __html: schema.svgPath } }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: schema.svgPath, fill: "none", stroke: "black", strokeWidth: 2, vectorEffect: "non-scaling-stroke" }) }), schema.ports.map((port) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Port_default, { componentId: id$10, port, selected: false, onClick: (portId, event) => onPortClick?.(id$10, portId, event) }, port.id)), selected && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", { x: 0, y: 0, width, height, ...outlineStyle }) ] }); }; var BaseComponent_default = BaseComponent; //#endregion //#region src/core/Brick.tsx const Brick = ({ component, onClick, onMouseDown, onPortClick, selected = false }) => { const schema = getComponentSchema(component.type); if (!schema) { console.warn(`No schema found for component type: ${component.type}`); return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { transform: `translate(${component.position.x}, ${component.position.y})`, onClick, onMouseDown, "data-component-id": component.id, "data-component-type": "unknown", children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", { width: 100, height: 50, fill: "red", stroke: "black", strokeWidth: 1.5, strokeDasharray: "5,5", vectorEffect: "non-scaling-stroke" }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("text", { x: 50, y: 25, textAnchor: "middle", dominantBaseline: "middle", fill: "white", fontFamily: "sans-serif", fontSize: "12px", children: ["Unknown: ", component.type] })] }); } return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BaseComponent_default, { schema, component, onClick, onMouseDown, onPortClick, selected }); }; var Brick_default = Brick; //#endregion //#region src/utils/getPortPosition.ts /** * Retrieves the absolute position of a port in the SVG coordinate system, * accounting for any viewport transformations (pan/zoom). * * @param componentId - The ID of the component * @param portId - The ID of the port on the component * @returns The position of the port or null if not found */ function getPortPosition(componentId, portId) { const portElement = document.querySelector(`[data-component-id="${componentId}"][data-port-id="${portId}"]`); if (!portElement) { console.warn(`Port not found: componentId=${componentId}, portId=${portId}`); return null; } let currentEl = portElement; let svgRoot = null; while (currentEl) { if (currentEl instanceof SVGSVGElement) { svgRoot = currentEl; break; } currentEl = currentEl.parentElement; } if (!svgRoot) { console.warn("SVG root not found for port"); return null; } const portRect = portElement.getBoundingClientRect(); const svgRect = svgRoot.getBoundingClientRect(); const clientX = portRect.left + portRect.width / 2; const clientY = portRect.top + portRect.height / 2; const svgPoint = svgRoot.createSVGPoint(); svgPoint.x = clientX; svgPoint.y = clientY; const ctm = svgRoot.getScreenCTM(); if (!ctm) { console.warn("Could not get screen CTM for SVG"); return null; } const inverseCTM = ctm.inverse(); const transformedPoint = svgPoint.matrixTransform(inverseCTM); return { x: transformedPoint.x, y: transformedPoint.y }; } /** * Hook version of getPortPosition for React components * Will be implemented in a separate usePortPosition.ts file */ //#endregion //#region src/core/WirePath.tsx const WirePath = ({ wire, components, selected = false, onClick }) => { const [fromPos, setFromPos] = (0, react.useState)({ x: 0, y: 0, found: false }); const [toPos, setToPos] = (0, react.useState)({ x: 0, y: 0, found: false }); const pathRef = (0, react.useRef)(null); const updatePortPositions = () => { try { const fromPosition = getPortPosition(wire.from.componentId, wire.from.portId); if (fromPosition) setFromPos({ ...fromPosition, found: true }); } catch (error) { console.warn(`Could not find source port for wire ${wire.id}:`, error); } try { const toPosition = getPortPosition(wire.to.componentId, wire.to.portId); if (toPosition) setToPos({ ...toPosition, found: true }); } catch (error) { console.warn(`Could not find destination port for wire ${wire.id}:`, error); } }; (0, react.useEffect)(() => { updatePortPositions(); const observer = new MutationObserver((mutations) => { let shouldUpdate = false; mutations.forEach((mutation) => { if (mutation.type === "attributes" && (mutation.attributeName === "transform" || mutation.attributeName === "x" || mutation.attributeName === "y")) shouldUpdate = true; }); if (shouldUpdate) updatePortPositions(); }); const fromComponent = document.querySelector(`[data-component-id="${wire.from.componentId}"]`); const toComponent = document.querySelector(`[data-component-id="${wire.to.componentId}"]`); if (fromComponent) observer.observe(fromComponent, { attributes: true, attributeFilter: [ "transform", "x", "y" ] }); if (toComponent) observer.observe(toComponent, { attributes: true, attributeFilter: [ "transform", "x", "y" ] }); const resizeObserver = new ResizeObserver(() => { updatePortPositions(); }); if (pathRef.current) { const svgElement = pathRef.current.closest("svg"); if (svgElement) resizeObserver.observe(svgElement); } return () => { observer.disconnect(); resizeObserver.disconnect(); }; }, [wire, components]); (0, react.useEffect)(() => { updatePortPositions(); }, [components.length]); if (!fromPos.found || !toPos.found) return null; const generatePath = (from, to) => { const dx = to.x - from.x; const dy = to.y - from.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance < 20) return `M ${from.x} ${from.y} L ${to.x} ${to.y}`; const controlPointLength = Math.min(distance / 3, 60); if (Math.abs(dx) > Math.abs(dy)) { const cp1x = from.x + controlPointLength; const cp1y = from.y; const cp2x = to.x - controlPointLength; const cp2y = to.y; return `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${to.x} ${to.y}`; } else { const cp1x = from.x; const cp1y = from.y + controlPointLength; const cp2x = to.x; const cp2y = to.y - controlPointLength; return `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${to.x} ${to.y}`; } }; const wireStyle = { stroke: selected ? "#4f8ef7" : wire.style?.color || "#333", strokeWidth: selected ? 3 : wire.style?.strokeWidth || 2, strokeDasharray: wire.style?.dashArray || "none", fill: "none", vectorEffect: "non-scaling-stroke" }; return /* @__PURE__ */ (0, react_jsx_runtime.jsx)( "path", // Reduced for smoother curves { ref: pathRef, d: generatePath(fromPos, toPos), className: `wire ${selected ? "selected" : ""}`, ...wireStyle, onClick, "data-wire-id": wire.id, "data-from-component": wire.from.componentId, "data-from-port": wire.from.portId, "data-to-component": wire.to.componentId, "data-to-port": wire.to.portId, style: { cursor: "pointer" } } ); }; var WirePath_default = WirePath; //#endregion //#region src/core/CircuitCanvas.tsx const CircuitCanvas = ({ components, wires, width = "100%", height = "100%", onComponentClick, onWireClick, onCanvasClick, onComponentDrag, onWireDrawStart, onWireDrawEnd, onWireDrawCancel, wireDrawing = { isDrawing: false, fromComponentId: null, fromPortId: null }, selectedComponentIds = [], selectedWireIds = [], showGrid = true, gridSize = 20, snapToGrid = false, onComponentDrop, initialZoom = 1, minZoom = .25, maxZoom = 3 }) => { const svgRef = (0, react.useRef)(null); const [dragState, setDragState] = (0, react.useState)({ isDragging: false, componentId: null, startPos: null, currentPos: null }); const [currentMousePos, setCurrentMousePos] = (0, react.useState)(null); const [sourcePortPos, setSourcePortPos] = (0, react.useState)(null); const [viewportTransform, setViewportTransform] = (0, react.useState)({ x: 0, y: 0, scale: initialZoom }); const [isPanning, setIsPanning] = (0, react.useState)(false); const [panStartPos, setPanStartPos] = (0, react.useState)(null); const isMouseDownRef = (0, react.useRef)(false); (0, react.useEffect)(() => { if (!svgRef.current) return; const viewportChangeEvent = new CustomEvent("viewport-change", { detail: viewportTransform, bubbles: true }); svgRef.current.dispatchEvent(viewportChangeEvent); }, [viewportTransform]); (0, react.useEffect)(() => { if (wireDrawing.isDrawing && wireDrawing.fromComponentId && wireDrawing.fromPortId) { const timer = setTimeout(() => { const portPos = getPortPosition(wireDrawing.fromComponentId, wireDrawing.fromPortId); if (portPos) setSourcePortPos(portPos); }, 0); return () => clearTimeout(timer); } else setSourcePortPos(null); }, [wireDrawing]); const screenToSvgCoordinates = (0, react.useCallback)((clientX, clientY) => { if (!svgRef.current) return { x: 0, y: 0 }; const svgRect = svgRef.current.getBoundingClientRect(); const x = (clientX - svgRect.left - viewportTransform.x) / viewportTransform.scale; const y = (clientY - svgRect.top - viewportTransform.y) / viewportTransform.scale; if (snapToGrid) return { x: Math.round(x / gridSize) * gridSize, y: Math.round(y / gridSize) * gridSize }; return { x, y }; }, [ gridSize, snapToGrid, viewportTransform ]); const getSvgCoordinates = (0, react.useCallback)((e) => { return screenToSvgCoordinates(e.clientX, e.clientY); }, [screenToSvgCoordinates]); const handleCanvasClick = (e) => { if (e.target === e.currentTarget) { if (!isPanning) { onCanvasClick?.(e); if (wireDrawing.isDrawing) onWireDrawCancel?.(); } } }; const handleComponentClick = (id$10, event) => { if (!dragState.isDragging && !isPanning) onComponentClick?.(id$10, event); }; const handleWireClick = (id$10, event) => { if (!isPanning) onWireClick?.(id$10, event); }; const handleMouseDown = (componentId, event) => { const startPos = getSvgCoordinates(event); setDragState({ isDragging: true, componentId, startPos, currentPos: startPos }); if (!selectedComponentIds.includes(componentId) && onComponentClick) onComponentClick(componentId, event); event.stopPropagation(); }; const handleCanvasMouseDown = (event) => { if (event.target === event.currentTarget && (event.button === 1 || event.button === 0 && event.altKey)) { setIsPanning(true); setPanStartPos({ x: event.clientX, y: event.clientY }); isMouseDownRef.current = true; event.preventDefault(); } }; const handleMouseMove = (event) => { if (isPanning && panStartPos) { const dx = event.clientX - panStartPos.x; const dy = event.clientY - panStartPos.y; setViewportTransform((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy })); setPanStartPos({ x: event.clientX, y: event.clientY }); return; } const currentPos = getSvgCoordinates(event); setCurrentMousePos(currentPos); if (dragState.isDragging && dragState.componentId) { setDragState({ ...dragState, currentPos }); const component = components.find((c) => c.id === dragState.componentId); if (component && dragState.startPos && currentPos) { const dx = currentPos.x - dragState.startPos.x; const dy = currentPos.y - dragState.startPos.y; const newPosition = { x: component.position.x + dx, y: component.position.y + dy }; if (snapToGrid) { newPosition.x = Math.round(newPosition.x / gridSize) * gridSize; newPosition.y = Math.round(newPosition.y / gridSize) * gridSize; } onComponentDrag?.(dragState.componentId, newPosition); } } }; const handleMouseUp = (event) => { if (dragState.isDragging) setDragState({ isDragging: false, componentId: null, startPos: null, currentPos: null }); if (isPanning) { setIsPanning(false); setPanStartPos(null); } isMouseDownRef.current = false; }; const handleMouseLeave = () => { if (isPanning) { setIsPanning(false); setPanStartPos(null); } isMouseDownRef.current = false; }; const handleWheel = (event) => { event.preventDefault(); if (event.ctrlKey || event.metaKey) { const delta = event.deltaY > 0 ? -.1 : .1; const newScale = Math.max(minZoom, Math.min(maxZoom, viewportTransform.scale + delta)); const svgRect = svgRef.current?.getBoundingClientRect(); if (!svgRect) return; const mouseX = event.clientX - svgRect.left; const mouseY = event.clientY - svgRect.top; setViewportTransform((prev) => { const worldX = (mouseX - prev.x) / prev.scale; const worldY = (mouseY - prev.y) / prev.scale; const newX = mouseX - worldX * newScale; const newY = mouseY - worldY * newScale; return { x: newX, y: newY, scale: newScale }; }); } else if (event.shiftKey) setViewportTransform((prev) => ({ ...prev, x: prev.x - event.deltaY })); else setViewportTransform((prev) => ({ ...prev, y: prev.y - event.deltaY })); }; const handlePortClick = (componentId, portId, event) => { if (isPanning) return; event.stopPropagation(); if (!wireDrawing.isDrawing) onWireDrawStart?.(componentId, portId); else if (wireDrawing.fromComponentId && wireDrawing.fromPortId) onWireDrawEnd?.(componentId, portId); }; const generateWirePath = (from, to) => { const dx = to.x - from.x; const dy = to.y - from.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance < 20) return `M ${from.x} ${from.y} L ${to.x} ${to.y}`; const controlPointLength = Math.min(distance / 3, 60); if (Math.abs(dx) > Math.abs(dy)) { const cp1x = from.x + controlPointLength; const cp1y = from.y; const cp2x = to.x - controlPointLength; const cp2y = to.y; return `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${to.x} ${to.y}`; } else { const cp1x = from.x; const cp1y = from.y + controlPointLength; const cp2x = to.x; const cp2y = to.y - controlPointLength; return `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${to.x} ${to.y}`; } }; const handleDragOver = (event) => { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; }; const handleDrop = (event) => { event.preventDefault(); const componentType = event.dataTransfer.getData("application/circuit-component"); if (!componentType || !onComponentDrop) return; const position = getSvgCoordinates(event); onComponentDrop(componentType, position); }; (0, react.useEffect)(() => { const handleKeyDown = (e) => { if (!svgRef.current) return; if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; if (e.code === "Space" && !isPanning && document.activeElement === document.body) e.preventDefault(); if (e.key === "0" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); setViewportTransform({ x: 0, y: 0, scale: 1 }); } if ((e.key === "+" || e.key === "=") && (e.ctrlKey || e.metaKey)) { e.preventDefault(); setViewportTransform((prev) => ({ ...prev, scale: Math.min(maxZoom, prev.scale + .1) })); } if (e.key === "-" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); setViewportTransform((prev) => ({ ...prev, scale: Math.max(minZoom, prev.scale - .1) })); } if (e.altKey && [ "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight" ].includes(e.key)) { e.preventDefault(); const panAmount = e.shiftKey ? 50 : 20; setViewportTransform((prev) => { let dx = 0, dy = 0; if (e.key === "ArrowLeft") dx = panAmount; if (e.key === "ArrowRight") dx = -panAmount; if (e.key === "ArrowUp") dy = panAmount; if (e.key === "ArrowDown") dy = -panAmount; return { ...prev, x: prev.x + dx, y: prev.y + dy }; }); } }; window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("keydown", handleKeyDown); }; }, [ isPanning, maxZoom, minZoom ]); const [showHelp, setShowHelp] = (0, react.useState)(true); (0, react.useEffect)(() => { if (showHelp) { const timer = setTimeout(() => { setShowHelp(false); }, 15e3); return () => clearTimeout(timer); } }, [showHelp]); const getCursorStyle = () => { if (isPanning) return "grabbing"; if (isMouseDownRef.current) return "grabbing"; if (wireDrawing.isDrawing) return "crosshair"; return "default"; }; const getGridSize = () => { const scaledGridSize = gridSize * (1 + (1 - Math.min(viewportTransform.scale, 1)) * 2); return scaledGridSize; }; return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)( "svg", // Reduced for smoother curves // Prevent page scroll // Faster panning with Shift { ref: svgRef, width, height, style: { backgroundColor: "#111111", userSelect: "none", cursor: getCursorStyle(), overflow: "hidden" }, onClick: handleCanvasClick, onMouseDown: handleCanvasMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onMouseLeave: handleMouseLeave, onWheel: handleWheel, onDragOver: handleDragOver, onDrop: handleDrop, "data-circuit-canvas": true, children: [ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("defs", { children: [ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("pattern", { id: "grid", width: getGridSize(), height: getGridSize(), patternUnits: "userSpaceOnUse", patternTransform: `translate(${viewportTransform.x % getGridSize()},${viewportTransform.y % getGridSize()}) scale(${viewportTransform.scale})`, children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", { width: getGridSize(), height: getGridSize(), fill: "#111111" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", { cx: getGridSize(), cy: getGridSize(), r: "0.5", fill: "#222222" })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("pattern", { id: "dots", width: getGridSize(), height: getGridSize(), patternUnits: "userSpaceOnUse", patternTransform: `translate(${viewportTransform.x % getGridSize()},${viewportTransform.y % getGridSize()}) scale(${viewportTransform.scale})`, children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", { width: getGridSize(), height: getGridSize(), fill: "#111111" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", { cx: getGridSize() / 2, cy: getGridSize() / 2, r: "0.7", fill: "#222222" })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("pattern", { id: "infinite-grid", width: getGridSize() * 5, height: getGridSize() * 5, patternUnits: "userSpaceOnUse", patternTransform: `translate(${viewportTransform.x % (getGridSize() * 5)},${viewportTransform.y % (getGridSize() * 5)}) scale(${viewportTransform.scale})`, children: [ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", { width: getGridSize() * 5, height: getGridSize() * 5, fill: "#111111" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: `M 0 0 H ${getGridSize() * 5} M 0 ${getGridSize()} H ${getGridSize() * 5} M 0 ${getGridSize() * 2} H ${getGridSize() * 5} M 0 ${getGridSize() * 3} H ${getGridSize() * 5} M 0 ${getGridSize() * 4} H ${getGridSize() * 5} M 0 0 V ${getGridSize() * 5} M ${getGridSize()} 0 V ${getGridSize() * 5} M ${getGridSize() * 2} 0 V ${getGridSize() * 5} M ${getGridSize() * 3} 0 V ${getGridSize() * 5} M ${getGridSize() * 4} 0 V ${getGridSize() * 5}`, stroke: "#222222", strokeWidth: "0.3" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: `M 0 0 H ${getGridSize() * 5} M 0 ${getGridSize() * 5} H ${getGridSize() * 5} M 0 0 V ${getGridSize() * 5} M ${getGridSize() * 5} 0 V ${getGridSize() * 5}`, stroke: "#333333", strokeWidth: "0.5" }) ] }) ] }), showGrid && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", { width: "100%", height: "100%", fill: "url(#infinite-grid)" }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { transform: `translate(${viewportTransform.x}, ${viewportTransform.y}) scale(${viewportTransform.scale})`, children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { className: "circuit-wires", children: [wires.map((wire) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WirePath_default, { wire, components, selected: selectedWireIds.includes(wire.id), onClick: (e) => handleWireClick(wire.id, e) }, wire.id)), wireDrawing.isDrawing && sourcePortPos && currentMousePos && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: generateWirePath(sourcePortPos, currentMousePos), stroke: "#4f8ef7", strokeWidth: 2, strokeDasharray: "5,5", fill: "none", className: "wire-preview", style: { pointerEvents: "visible" } })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("g", { className: "circuit-components", children: components.map((component) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Brick_default, { component, selected: selectedComponentIds.includes(component.id), onClick: (e) => handleComponentClick(component.id, e), onMouseDown: (e) => handleMouseDown(component.id, e), onPortClick: handlePortClick }, component.id)) })] }), process.env.NODE_ENV === "development" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("text", { x: "10", y: "20", fill: "#666", fontSize: "12", pointerEvents: "none", children: `Position: (${Math.round(viewportTransform.x)}, ${Math.round(viewportTransform.y)}) | Zoom: ${viewportTransform.scale.toFixed(2)}` }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { className: "circuit-minimap", transform: `translate(10, ${typeof height === "number" ? height - 120 : "calc(100% - 120px)"})`, children: [ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", { x: "0", y: "0", width: "110", height: "110", fill: "#222", fillOpacity: "0.7", stroke: "#444", strokeWidth: "1", rx: "4" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", { x: 5, y: 5, width: 100, height: 100, fill: "none", stroke: "#333", strokeWidth: "1" }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { transform: "scale(0.1) translate(50, 50)", children: [components.map((component) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", { cx: component.position.x, cy: component.position.y, r: 15, fill: selectedComponentIds.includes(component.id) ? "#4f8ef7" : "#666" }, component.id)), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", { x: -viewportTransform.x / viewportTransform.scale, y: -viewportTransform.y / viewportTransform.scale, width: typeof width === "number" ? width / viewportTransform.scale : 1e3 / viewportTransform.scale, height: typeof height === "number" ? height / viewportTransform.scale : 800 / viewportTransform.scale, fill: "none", stroke: "#4f8ef7", strokeWidth: "10", strokeDasharray: "20,10" })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", { x: "85", y: "0", width: "25", height: "25", fill: "#333", rx: "4", style: { cursor: "pointer" }, onClick: () => { const minimapEl = document.querySelector(".circuit-minimap"); if (minimapEl) minimapEl.setAttribute("style", "display: none"); } }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("text", { x: "93", y: "17", fill: "#fff", fontSize: "16", style: { cursor: "pointer" }, children: "×" }) ] }), showHelp && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { transform: `translate(${typeof width === "number" ? width / 2 - 150 : "50%"}, 30)`, children: [ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", { x: "-10", y: "-10", width: "320", height: "85", rx: "8", fill: "rgba(0, 0, 0, 0.7)", stroke: "#4f8ef7", strokeWidth: "1" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("text", { x: "0", y: "15", fill: "#fff", fontSize: "14", fontWeight: "bold", children: "Infinite Canvas Controls:" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("text", { x: "0", y: "35", fill: "#eee", fontSize: "12", children: "• Alt + drag/middle mouse: Pan canvas" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("text", { x: "0", y: "50", fill: "#eee", fontSize: "12", children: "• Ctrl/⌘ + scroll: Zoom in/out" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("text", { x: "0", y: "65", fill: "#eee", fontSize: "12", children: "• Ctrl/⌘ + 0: Reset view" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", { x: "290", y: "-10", width: "20", height: "20", rx: "4", fill: "rgba(100, 100, 100, 0.5)", cursor: "pointer", onClick: () => setShowHelp(false) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("text", { x: "296", y: "5", fill: "#fff", fontSize: "16", cursor: "pointer", onClick: () => setShowHelp(false), children: "×" }) ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { transform: "translate(10, 10)", style: { opacity: .7 }, onMouseEnter: (e) => e.currentTarget.style.opacity = "1", onMouseLeave: (e) => e.currentTarget.style.opacity = "0.7", children: [ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", { x: "0", y: "0", width: "80", height: "22", rx: "4", fill: "#222", fillOpacity: "0.8" }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { transform: "translate(10, 11)", cursor: "pointer", onClick: () => setViewportTransform((prev) => ({ ...prev, scale: Math.min(maxZoom, prev.scale + .1) })), children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", { r: "8", fill: "#333" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M-4,0 H4 M0,-4 V4", stroke: "#aaa", strokeWidth: "1.5" })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { transform: "translate(40, 11)", cursor: "pointer", onClick: () => setViewportTransform((prev) => ({ ...prev, scale: Math.max(minZoom, prev.scale - .1) })), children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", { r: "8", fill: "#333" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M-4,0 H4", stroke: "#aaa", strokeWidth: "1.5" })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { transform: "translate(70, 11)", cursor: "pointer", onClick: () => setViewportTransform({ x: 0, y: 0, scale: 1 }), children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", { r: "8", fill: "#333" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M-3,-3 L3,3 M-3,3 L3,-3", stroke: "#aaa", strokeWidth: "1.5" })] }) ] }) ] } ); }; var CircuitCanvas_default = CircuitCanvas; //#endregion //#region src/hooks/useCircuit.ts /** * React hook for managing circuit state * * @param initialState - Optional initial state for the circuit * @returns A tuple containing the circuit state and action functions */ function useCircuit(initialState) { const undoStack = (0, react.useRef)([]); const redoStack = (0, react.useRef)([]); const [state, setState] = (0, react.useState)({ components: initialState?.components || [], wires: initialState?.wires || [], selectedComponentIds: initialState?.selectedComponentIds || [], selectedWireIds: initialState?.selectedWireIds || [] }); const saveStateForUndo = (0, react.useCallback)((prevState) => { undoStack.current.push(JSON.parse(JSON.stringify(prevState))); redoStack.current = []; }, []); const setStateWithHistory = (0, react.useCallback)((updater) => { setState((prev) => { saveStateForUndo(prev); return updater(prev); }); }, [saveStateForUndo]); const generateId = (0, react.useCallback)((prefix) => { return `${prefix}-${Math.random().toString(36).substring(2, 11)}`; }, []); const addComponent = (0, react.useCallback)((component) => { const id$10 = generateId("component"); setStateWithHistory((prev) => ({ ...prev, components: [...prev.components, { ...component, id: id$10 }] })); return id$10; }, [generateId, setStateWithHistory]); const updateComponent = (0, react.useCallback)((id$10, updates) => { setStateWithHistory((prev) => ({ ...prev, components: prev.components.map((c) => c.id === id$10 ? { ...c, ...updates } : c) })); }, [setStateWithHistory]); const removeComponent = (0, react.useCallback)((id$10) => { setStateWithHistory((prev) => { const components = prev.components.filter((c) => c.id !== id$10); const wires = prev.wires.filter((w) => w.from.componentId !== id$10 && w.to.componentId !== id$10); const selectedComponentIds = prev.selectedComponentIds.filter((selectedId) => selectedId !== id$10); return { ...prev, components, wires, selectedComponentIds }; }); }, [setStateWithHistory]); const addWire = (0, react.useCallback)((from, to, style) => { const id$10 = generateId("wire"); setStateWithHistory((prev) => ({ ...prev, wires: [...prev.wires, { id: id$10, from, to, style }] })); return id$10; }, [generateId, setStateWithHistory]); const updateWire = (0, react.useCallback)((id$10, updates) => { setStateWithHistory((prev) => ({ ...prev, wires: prev.wires.map((w) => w.id === id$10 ? { ...w, ...updates } : w) })); }, [setStateWithHistory]); const removeWire = (0, react.useCallback)((id$10) => { setStateWithHistory((prev) => ({ ...prev, wires: prev.wires.filter((w) => w.id !== id$10), selectedWireIds: prev.selectedWireIds.filter((wireId) => wireId !== id$10) })); }, [setStateWithHistory]); const selectComponent = (0, react.useCallback)((id$10, addToSelection = false) => { setState((prev) => { const selectedComponentIds = addToSelection ? [...prev.selectedComponentIds, id$10] : [id$10]; return { ...prev, selectedComponentIds: [...new Set(selectedComponentIds)] }; }); }, []); const selectWire = (0, react.useCallback)((id$10, addToSelection = false) => { setState((prev) => { const selectedWireIds = addToSelection ? [...prev.selectedWireIds, id$10] : [id$10]; return { ...prev, selectedWireIds: [...new Set(selectedWireIds)] }; }); }, []); const deselectAll = (0, react.useCallback)(() => { setState((prev) => ({ ...prev, selectedComponentIds: [], selectedWireIds: [] })); }, []); const getSelectedComponents = (0, react.useCallback)(() => { return state.components.filter((c) => state.selectedComponentIds.includes(c.id)); }, [state.components, state.selectedComponentIds]); const getSelectedWires = (0, react.useCallback)(() => { return state.wires.filter((w) => state.selectedWireIds.includes(w.id)); }, [state.wires, state.selectedWireIds]); const moveComponent = (0, react.useCallback)((id$10, newPosition) => { setStateWithHistory((prev) => ({ ...prev, components: prev.components.map((c) => c.id === id$10 ? { ...c, position: newPosition } : c) })); }, [setStateWithHistory]); const moveSelectedComponents = (0, react.useCallback)((dx, dy) => { setStateWithHistory((prev) => ({ ...prev, components: prev.components.map((c) => { if (prev.selectedComponentIds.includes(c.id)) return { ...c, position: { x: c.position.x + dx, y: c.position.y + dy } }; return c; }) })); }, [setStateWithHistory]); const rotateComponent = (0, react.useCallback)((id$10, degrees) => { setStateWithHistory((prev) => ({ ...prev, components: prev.components.map((c) => { if (c.id === id$10) { const currentRotation = c.rotation || 0; const newRotation = (currentRotation + degrees) % 360; return { ...c, rotation: newRotation < 0 ? newRotation + 360 : newRotation }; } return c; }) })); }, [setStateWithHistory]); const rotateSelectedComponents = (0, react.useCallback)((degrees) => { setStateWithHistory((prev) => ({ ...prev, components: prev.components.map((c) => { if (prev.selectedComponentIds.includes(c.id)) { const currentRotation = c.rotation || 0; const newRotation = (currentRotation + degrees) % 360; return { ...c, rotation: newRotation < 0 ? newRotation + 360 : newRotation }; } return c; }) })); }, [setStateWithHistory]); const undo = (0, react.useCallback)(() => { if (undoStack.current.length > 0) { redoStack.current.push(JSON.parse(JSON.stringify(state))); const prevState = undoStack.current.pop(); setState(prevState); } }, [state]); const redo = (0, react.useCallback)(() => { if (redoStack.current.length > 0) { undoStack.current.push(JSON.parse(JSON.string