class WiringApp { constructor() { this.diagramId = null; this.currentViewId = null; // null = main layout, number = view id this._views = []; // view objects for current diagram this._savedTimer = null; this._customConnectors = []; // loaded from backend this._editingConnId = null; // id of custom connector being edited in modal this._clipboard = null; // copied device data this._pasteCount = 0; // paste offset counter this._undoStack = []; // array of async undo functions this._preDrag = null; // snapshot captured at dragstart for undo this._octopartReady = null; // null=unknown, true/false after status check this._bundles = []; // wire bundles for current diagram this._updatingBundleDropdown = false; this._copiedWireStyle = null; // clipboard for wire style copy/paste this.canvas = new DiagramCanvas("canvas-container", { onDeviceSelected: (d) => this._showDeviceProps(d), onWireSelected: (w) => this._showWireProps(w), onSelectionCleared: () => this._clearProps(), onMultipleSelected: (count) => this._showMultiProps(count), onDeviceMoved: (id, x, y) => { this._savePosForCurrentView(id, x, y); this._flashSaved(); }, onDeviceDropped: (type, wx, wy) => this.addDevice(type, wx, wy), onConnectorDropped: (connId, wx, wy) => this.addConnector(connId, wx, wy), onWireCreated: (fDev, fPin, tDev, tPin) => this.createWire(fDev, fPin, tDev, tPin), onWaypointsChanged: (id, wps) => { this._saveWaypointsForCurrentView(id, wps); this._flashSaved(); }, onDragStarted: (snap) => { this._preDrag = snap; }, onDragEnded: (ids) => { this._pushMoveUndo(ids); }, onDeviceContextMenu: (id, x, y) => this._showCtxDevice(id, x, y), onWireContextMenu: (id, x, y) => this._showCtxWire(id, x, y), onMultiWireSelected: (ids) => this._onMultiWireSelected(ids), onPinoutRequested: (d) => this._showPinoutPopup(d), onDeviceResized: async (id, x, y, w, h) => { const device = this.canvas.deviceData.get(id); if (!device) return; this._recalcPinOffsets(device.pins || [], w, h, device); await api.devices.update(id, { x, y, width: w, height: h, pins: device.pins }).catch(console.error); this.canvas.updateDevice(device); this.canvas.selectDevice(id); this._flashSaved(); }, }); this._fb = null; // FormboardCanvas instance (lazy) this._fbActive = false; this._buildDeviceLibrary(); this._bindToolbar(); this._bindKeyboard(); this._bindProps(); this._bindTabs(); this._bindConnectorModal(); this._bindPinoutModal(); this._bindLibrarySearch(); this._bindFormboard(); this.loadDiagramList() .then(() => this._autoOpenLast()) .then(() => this.loadCustomConnectors()) .then(() => this._checkOctopartStatus()); } // ── View-aware position/waypoint saving ─────────────────────────────────────── _savePosForCurrentView(id, x, y) { if (this.currentViewId) { api.views.updateDevicePos(this.currentViewId, id, { x, y }).catch(console.error); } else { api.devices.update(id, { x, y }).catch(console.error); } } _saveWaypointsForCurrentView(wireId, waypoints) { if (this.currentViewId) { api.views.updateWireWaypoints(this.currentViewId, wireId, { waypoints }).catch(console.error); } else { api.wires.update(wireId, { waypoints }).catch(console.error); } } // ── Auto-reopen & saved indicator ──────────────────────────────────────────── _autoOpenLast() { const lastId = localStorage.getItem("lastDiagramId"); if (lastId) return this.openDiagram(parseInt(lastId)); } _flashSaved() { clearTimeout(this._savedTimer); this._savedTimer = setTimeout(() => { const el = document.getElementById("saved-indicator"); if (!el) return; el.textContent = "✓ Saved"; el.style.color = ""; el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 1400); }, 600); } _flashError() { clearTimeout(this._savedTimer); const el = document.getElementById("saved-indicator"); if (!el) return; el.textContent = "✗ Save failed"; el.style.color = "#e06c6c"; el.classList.add("show"); this._savedTimer = setTimeout(() => { el.classList.remove("show"); el.style.color = ""; }, 3000); } // ── Align & Distribute ──────────────────────────────────────────────────────── async _alignDevices(edge) { const ids = [...this.canvas.selectedIds]; if (ids.length < 2) return; const boxes = ids.map(id => { const d = this.canvas.deviceData.get(id); return { id, x: d.x, y: d.y, w: d.width || 80, h: d.height || 60 }; }); let target; if (edge === "left") target = Math.min(...boxes.map(b => b.x)); if (edge === "right") target = Math.max(...boxes.map(b => b.x + b.w)); if (edge === "top") target = Math.min(...boxes.map(b => b.y)); if (edge === "bottom") target = Math.max(...boxes.map(b => b.y + b.h)); for (const b of boxes) { const d = this.canvas.deviceData.get(b.id); if (edge === "left") d.x = target; if (edge === "right") d.x = target - b.w; if (edge === "top") d.y = target; if (edge === "bottom") d.y = target - b.h; this.canvas.updateDevice(d); await api.devices.update(b.id, { x: d.x, y: d.y }).catch(console.error); } this._flashSaved(); } async _distributeDevices(axis) { const ids = [...this.canvas.selectedIds]; if (ids.length < 3) return; const boxes = ids.map(id => { const d = this.canvas.deviceData.get(id); return { id, x: d.x, y: d.y, w: d.width || 80, h: d.height || 60 }; }); if (axis === "h") { boxes.sort((a, b) => a.x - b.x); const totalW = boxes.reduce((s, b) => s + b.w, 0); const gap = (boxes[boxes.length - 1].x + boxes[boxes.length - 1].w - boxes[0].x - totalW) / (boxes.length - 1); let cursor = boxes[0].x + boxes[0].w; for (let i = 1; i < boxes.length - 1; i++) { const d = this.canvas.deviceData.get(boxes[i].id); d.x = Math.round(cursor + gap); this.canvas.updateDevice(d); await api.devices.update(boxes[i].id, { x: d.x }).catch(console.error); cursor = d.x + boxes[i].w; } } else { boxes.sort((a, b) => a.y - b.y); const totalH = boxes.reduce((s, b) => s + b.h, 0); const gap = (boxes[boxes.length - 1].y + boxes[boxes.length - 1].h - boxes[0].y - totalH) / (boxes.length - 1); let cursor = boxes[0].y + boxes[0].h; for (let i = 1; i < boxes.length - 1; i++) { const d = this.canvas.deviceData.get(boxes[i].id); d.y = Math.round(cursor + gap); this.canvas.updateDevice(d); await api.devices.update(boxes[i].id, { y: d.y }).catch(console.error); cursor = d.y + boxes[i].h; } } this._flashSaved(); } // ── Undo ────────────────────────────────────────────────────────────────────── _pushUndo(fn) { this._undoStack.push(fn); if (this._undoStack.length > 50) this._undoStack.shift(); } async _undo() { const fn = this._undoStack.pop(); if (fn) await fn(); } _pushMoveUndo(movedIds) { if (!this._preDrag) return; const { positions, waypoints } = this._preDrag; this._preDrag = null; this._pushUndo(async () => { for (const [id, { x, y }] of positions) { const g = this.canvas.deviceNodes.get(id); const d = this.canvas.deviceData.get(id); if (g) { g.x(x); g.y(y); } if (d) { d.x = x; d.y = y; } this._savePosForCurrentView(id, x, y); this.canvas._redrawWiresFor(id); } for (const [wId, wps] of waypoints) { const wire = this.canvas.wireData.get(wId); if (wire) { wire.waypoints = wps.map(wp => ({ ...wp })); this._saveWaypointsForCurrentView(wId, wire.waypoints); } } this.canvas.deviceLayer.batchDraw(); this.canvas.wireLayer.batchDraw(); this._flashSaved(); }); } // ── Device library ──────────────────────────────────────────────────────────── _buildDeviceLibrary() { const container = document.getElementById("device-library"); container.innerHTML = ""; Object.entries(DEVICE_TYPES).forEach(([key, type]) => { const item = document.createElement("div"); item.className = "lib-item"; item.draggable = true; item.dataset.type = key; item.innerHTML = `${type.label}`; item.title = type.description; item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("device_type", key); e.dataTransfer.effectAllowed = "copy"; }); item.addEventListener("dblclick", () => { if (!this.diagramId) return this._needDiagram(); this.addDevice(key, 200 + Math.random() * 100, 150 + Math.random() * 100); }); container.appendChild(item); }); } // ── Connector library ───────────────────────────────────────────────────────── async loadCustomConnectors() { try { this._customConnectors = await api.connectors.list(); } catch { this._customConnectors = []; } this._filterConnectors(); } _bindLibrarySearch() { document.getElementById("lib-search")?.addEventListener("input", () => this._filterConnectors()); document.getElementById("lib-category")?.addEventListener("change", () => this._filterConnectors()); } _buildConnectorLibrary() { const catSelect = document.getElementById("lib-category"); catSelect.innerHTML = ''; const cats = new Set(Object.keys(CONNECTOR_LIBRARY)); this._customConnectors.forEach(c => cats.add(c.category || "Custom")); cats.forEach(cat => { const opt = document.createElement("option"); opt.value = cat; opt.textContent = cat; catSelect.appendChild(opt); }); } _filterConnectors() { const savedCat = document.getElementById("lib-category")?.value || ""; this._buildConnectorLibrary(); // refresh category list const catSelect = document.getElementById("lib-category"); if (catSelect && savedCat) catSelect.value = savedCat; // restore selection after innerHTML rebuild const query = (document.getElementById("lib-search")?.value || "").toLowerCase(); const catFilter = catSelect?.value || ""; const list = document.getElementById("lib-list"); list.innerHTML = ""; // ── Custom connectors (from DB) ────────────────────────────────────────── const customs = this._customConnectors.filter(conn => { if (catFilter && (conn.category || "Custom") !== catFilter) return false; if (query && !`${conn.name} ${conn.manufacturer} ${conn.part_number} ${conn.description}`.toLowerCase().includes(query)) return false; return true; }); if (customs.length) { const header = document.createElement("div"); header.className = "lib-section-header"; header.textContent = "Custom Connectors"; list.appendChild(header); customs.forEach(conn => list.appendChild(this._makeCustomItem(conn))); } // ── Built-in library ───────────────────────────────────────────────────── Object.entries(CONNECTOR_LIBRARY).forEach(([cat, conns]) => { if (catFilter && cat !== catFilter) return; const matched = conns.filter(conn => !query || `${conn.name} ${conn.manufacturer} ${conn.partNumber} ${conn.description}`.toLowerCase().includes(query)); if (!matched.length) return; const header = document.createElement("div"); header.className = "lib-section-header"; header.textContent = cat; list.appendChild(header); matched.forEach(conn => list.appendChild(this._makeBuiltinItem(conn))); }); if (!list.children.length) { list.innerHTML = '
No connectors match
'; } } _makeCustomItem(conn) { const item = document.createElement("div"); item.className = "lib-conn-item"; item.draggable = true; item.title = conn.description || conn.name; item.innerHTML = `| # | Color | Note / Connection |
|---|