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), onDeviceResized: async (id, x, y, w, h) => { const device = this.canvas.deviceData.get(id); if (!device) return; this._recalcPinOffsets(device.pins || [], w, h); 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._buildDeviceLibrary(); this._bindToolbar(); this._bindKeyboard(); this._bindProps(); this._bindTabs(); this._bindConnectorModal(); this._bindLibrarySearch(); 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.icon}${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() { this._buildConnectorLibrary(); // refresh category list const query = (document.getElementById("lib-search")?.value || "").toLowerCase(); const catFilter = document.getElementById("lib-category")?.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 = `
${conn.name} custom
${conn.manufacturer || "—"} · ${conn.pin_count}p${conn.part_number ? " · " + conn.part_number : ""}
`; item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("connector_id", "custom_" + conn.id); e.dataTransfer.effectAllowed = "copy"; }); item.addEventListener("dblclick", (e) => { if (e.target.classList.contains("conn-edit-btn")) return; if (!this.diagramId) return this._needDiagram(); this.addConnector("custom_" + conn.id, 220, 160); }); item.querySelector(".conn-edit-btn").addEventListener("click", (e) => { e.stopPropagation(); this.openConnectorModal(conn); }); return item; } _makeBuiltinItem(conn) { const item = document.createElement("div"); item.className = "lib-conn-item"; item.draggable = true; item.title = conn.description || conn.name; item.innerHTML = `
${conn.name}
${conn.manufacturer} · ${conn.pinCount}p · ${conn.partNumber}
`; item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("connector_id", conn.id); e.dataTransfer.effectAllowed = "copy"; }); item.addEventListener("dblclick", () => { if (!this.diagramId) return this._needDiagram(); this.addConnector(conn.id, 220 + Math.random() * 80, 160 + Math.random() * 80); }); return item; } // ── Custom connector modal ──────────────────────────────────────────────────── _bindConnectorModal() { document.getElementById("btn-new-connector")?.addEventListener("click", () => this.openConnectorModal(null)); document.getElementById("cm-save")?.addEventListener("click", () => this._saveConnectorModal()); document.getElementById("cm-delete")?.addEventListener("click", () => this._deleteConnectorModal()); document.getElementById("cm-cancel")?.addEventListener("click", () => this.closeConnectorModal()); // Close on backdrop click document.getElementById("connector-modal")?.addEventListener("click", (e) => { if (e.target.id === "connector-modal") this.closeConnectorModal(); }); // Escape key document.addEventListener("keydown", (e) => { if (e.key === "Escape" && document.getElementById("connector-modal")?.style.display !== "none") { this.closeConnectorModal(); } }); // Update pin labels when pin count changes document.getElementById("cm-pincount")?.addEventListener("input", (e) => { const count = Math.max(1, Math.min(64, parseInt(e.target.value) || 1)); const existing = [...document.querySelectorAll(".pin-label-input")].map(i => i.value); this._buildPinLabelInputs(count, existing); }); } openConnectorModal(conn = null) { this._editingConnId = conn?.id ?? null; document.getElementById("cm-modal-title").textContent = conn ? "Edit Custom Connector" : "New Custom Connector"; document.getElementById("cm-name").value = conn?.name || ""; document.getElementById("cm-category").value = conn?.category || "Custom"; document.getElementById("cm-manufacturer").value = conn?.manufacturer || ""; document.getElementById("cm-partnumber").value = conn?.part_number || ""; document.getElementById("cm-description").value = conn?.description || ""; document.getElementById("cm-pincount").value = conn?.pin_count || 4; document.getElementById("cm-delete").style.display = (conn?.id != null) ? "" : "none"; this._buildPinLabelInputs(conn?.pin_count || 4, conn?.pin_labels || []); document.getElementById("connector-modal").style.display = "flex"; document.getElementById("cm-name").focus(); } closeConnectorModal() { document.getElementById("connector-modal").style.display = "none"; this._editingConnId = null; } _buildPinLabelInputs(count, existingLabels = []) { const container = document.getElementById("cm-pin-labels"); container.innerHTML = ""; for (let i = 0; i < count; i++) { const row = document.createElement("div"); row.className = "pin-label-row"; row.innerHTML = `${i + 1} `; container.appendChild(row); } } async _saveConnectorModal() { const name = document.getElementById("cm-name").value.trim(); const pinCount = parseInt(document.getElementById("cm-pincount").value); if (!name) { document.getElementById("cm-name").focus(); return; } if (!pinCount || pinCount < 1 || pinCount > 64) { alert("Pin count must be 1–64"); return; } const data = { name, category: document.getElementById("cm-category").value.trim() || "Custom", manufacturer: document.getElementById("cm-manufacturer").value.trim(), part_number: document.getElementById("cm-partnumber").value.trim(), description: document.getElementById("cm-description").value.trim(), pin_count: pinCount, pin_labels: [...document.querySelectorAll(".pin-label-input")].map(i => i.value.trim()), }; const btn = document.getElementById("cm-save"); btn.disabled = true; btn.textContent = "Saving…"; try { if (this._editingConnId != null) { await api.connectors.update(this._editingConnId, data); } else { await api.connectors.create(data); } this.closeConnectorModal(); await this.loadCustomConnectors(); this._flashSaved(); } catch (e) { alert("Failed to save connector: " + e.message); } finally { btn.disabled = false; btn.textContent = "Save"; } } async _deleteConnectorModal() { if (this._editingConnId == null) return; if (!confirm(`Delete "${document.getElementById("cm-name").value}"? This cannot be undone.`)) return; try { await api.connectors.delete(this._editingConnId); this.closeConnectorModal(); await this.loadCustomConnectors(); this._flashSaved(); } catch (e) { alert("Failed to delete: " + e.message); } } // ── Tabs ────────────────────────────────────────────────────────────────────── _bindTabs() { document.querySelectorAll(".sidebar-tab").forEach(btn => { btn.addEventListener("click", () => { document.querySelectorAll(".sidebar-tab").forEach(b => b.classList.remove("active")); btn.classList.add("active"); document.querySelectorAll(".tab-panel").forEach(p => { p.style.display = p.dataset.panel === btn.dataset.tab ? "" : "none"; }); }); }); } // ── Toolbar ─────────────────────────────────────────────────────────────────── _bindToolbar() { const on = (id, fn) => document.getElementById(id)?.addEventListener("click", fn); on("btn-select", () => this.setMode("select")); on("btn-wire", () => this.setMode("wire")); on("btn-delete", () => this.deleteSelected()); on("btn-duplicate", () => this.duplicateSelected()); on("btn-undo", () => this._undo()); on("btn-fit", () => this.canvas.fitView()); on("btn-new", () => this.newDiagram()); on("btn-bom", () => this._export("bom")); on("btn-assembly", () => this._export("assembly")); on("btn-json", () => this._export("json")); on("btn-img", () => { const n = document.getElementById("diagram-name")?.value || "diagram"; this.canvas.exportImage(n); }); on("btn-formboard", () => this._export("formboard")); on("btn-git", () => this._openGitModal()); on("btn-drc", () => this._runDrc()); on("btn-import", () => document.getElementById("import-file-input")?.click()); document.getElementById("import-file-input")?.addEventListener("change", (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = async (ev) => { try { const data = JSON.parse(ev.target.result); const diag = await api.diagrams.import(data); await this.loadDiagramList(); await this.openDiagram(diag.id); } catch (err) { alert("Import failed: " + err.message); } e.target.value = ""; }; reader.readAsText(file); }); on("btn-jumps", () => { const btn = document.getElementById("btn-jumps"); const enabled = !this.canvas.jumpsEnabled; this.canvas.setJumpsEnabled(enabled); btn?.classList.toggle("active", enabled); }); on("btn-snap", () => { const btn = document.getElementById("btn-snap"); const enabled = !this.canvas.snapEnabled; this.canvas.toggleSnap(enabled); btn?.classList.toggle("active", enabled); }); on("btn-harness", () => { const btn = document.getElementById("btn-harness"); const enabled = !this.canvas.harnessMode; this.canvas.setHarnessMode(enabled); btn?.classList.toggle("active", enabled); }); on("btn-route-ortho", () => this._setRouteMode("ortho")); on("btn-route-direct", () => this._setRouteMode("direct")); on("btn-route-curved", () => this._setRouteMode("curved")); document.getElementById("export-toggle")?.addEventListener("click", (e) => { e.stopPropagation(); document.getElementById("export-menu").classList.toggle("open"); }); document.addEventListener("click", (e) => { document.getElementById("export-menu")?.classList.remove("open"); this._closeCtx(); if (!e.target.closest("#octopart-results") && !e.target.closest("#btn-octopart-search")) { const res = document.getElementById("octopart-results"); if (res) res.style.display = "none"; } }); document.addEventListener("contextmenu", (e) => { // Close ctx menu if right-clicking outside canvas (browser default handles the rest) if (!e.target.closest("#canvas-container")) this._closeCtx(); }); document.getElementById("diagram-name")?.addEventListener("change", async (e) => { if (this.diagramId) { await api.diagrams.update(this.diagramId, { name: e.target.value }).catch(console.error); this._flashSaved(); } }); } // ── Context menu ────────────────────────────────────────────────────────────── _ctxMenu() { return document.getElementById("ctx-menu"); } _openCtx(x, y, items) { const menu = this._ctxMenu(); menu.innerHTML = items.map(item => { if (item === "---") return `
`; return `
${item.icon ? `${item.icon}` : ""}${item.label}
`; }).join(""); menu.classList.add("open"); // Position, keeping within viewport const vw = window.innerWidth, vh = window.innerHeight; const mw = 180, mh = menu.childElementCount * 32; menu.style.left = (x + mw > vw ? x - mw : x) + "px"; menu.style.top = (y + mh > vh ? y - mh : y) + "px"; } _closeCtx() { this._ctxMenu().classList.remove("open"); } _showCtxDevice(id, x, y) { const d = this.canvas.deviceData.get(id); const count = this.canvas.selectedDeviceIds.size; const isMulti = count > 1; const isGroup = d?.device_type === "group"; const isLocked = !!d?.properties?.locked; const items = isMulti ? [ { action: "dup", icon: "⊕", label: `Duplicate ${count} items` }, { action: "zoom", icon: "⊞", label: "Zoom to selection" }, "---", { action: "align-left", icon: "⇤", label: "Align Left" }, { action: "align-right", icon: "⇥", label: "Align Right" }, { action: "align-top", icon: "⇡", label: "Align Top" }, { action: "align-bottom", icon: "⇣", label: "Align Bottom" }, ...(count >= 3 ? [ "---", { action: "dist-h", icon: "↔", label: "Distribute Horizontally" }, { action: "dist-v", icon: "↕", label: "Distribute Vertically" }, ] : []), "---", { action: "del", icon: "✕", label: `Delete ${count} items`, danger: true }, ] : [ { action: "dup", icon: "⊕", label: "Duplicate" }, ...(!isGroup ? [{ action: "addpin", icon: "+", label: "Add Pin" }] : []), ...(!isGroup ? [{ action: "savelb", icon: "⬆", label: "Save to Library" }] : []), "---", { action: "front", icon: "▲", label: "Bring to Front" }, { action: "back", icon: "▼", label: "Send to Back" }, "---", isLocked ? { action: "unlock", icon: "🔓", label: "Unlock Position" } : { action: "lock", icon: "🔒", label: "Lock Position" }, "---", { action: "del", icon: "✕", label: "Delete", danger: true }, ]; this._openCtx(x, y, items); this._ctxMenu().onclick = async (e) => { const action = e.target.closest(".ctx-item")?.dataset.action; this._closeCtx(); if (!action) return; if (action === "dup") this.duplicateSelected(); if (action === "del") this.deleteSelected(); if (action === "zoom") this.canvas.zoomToSelection(); if (action === "addpin" && d) this._addDevicePin(d); if (action === "savelb" && d) this._saveDeviceAsConnector(d); if (action === "front") { this.canvas.bringToFront(id); const dd = this.canvas.deviceData.get(id); if (dd) { dd.properties = { ...(dd.properties||{}), zOrder: Date.now() }; api.devices.update(id, { properties: dd.properties }).catch(console.error); } } if (action === "back") { this.canvas.sendToBack(id); const dd = this.canvas.deviceData.get(id); if (dd) { dd.properties = { ...(dd.properties||{}), zOrder: -Date.now() }; api.devices.update(id, { properties: dd.properties }).catch(console.error); } } if (action === "lock") { this.canvas.setDeviceLocked(id, true); const dd = this.canvas.deviceData.get(id); if (dd) await api.devices.update(id, { properties: dd.properties }).catch(console.error); } if (action === "unlock") { this.canvas.setDeviceLocked(id, false); const dd = this.canvas.deviceData.get(id); if (dd) await api.devices.update(id, { properties: dd.properties }).catch(console.error); } if (action === "align-left") this._alignDevices("left"); if (action === "align-right") this._alignDevices("right"); if (action === "align-top") this._alignDevices("top"); if (action === "align-bottom") this._alignDevices("bottom"); if (action === "dist-h") this._distributeDevices("h"); if (action === "dist-v") this._distributeDevices("v"); }; } _showCtxWire(id, x, y) { const multiIds = this.canvas.selectedWireIds; if (multiIds.size > 1 && multiIds.has(id)) { // Multi-wire context menu const items = [ { action: "multi-clearwp", icon: "⌀", label: `Clear waypoints (${multiIds.size} wires)` }, { action: "multi-autoroute", icon: "⤢", label: `Auto-route (${multiIds.size} wires)` }, ]; this._openCtx(x, y, items); this._ctxMenu().onclick = async (e) => { const action = e.target.closest(".ctx-item")?.dataset.action; this._closeCtx(); if (!action) return; if (action === "multi-clearwp") { for (const wid of multiIds) { const w = this.canvas.wireData.get(wid); if (!w) continue; w.waypoints = []; this.canvas.updateWire(w); api.wires.update(wid, { waypoints: [] }).catch(console.error); } this._flashSaved(); } if (action === "multi-autoroute") { for (const wid of multiIds) { const newWps = this.canvas.autoRouteWire(wid); if (newWps !== null) { api.wires.update(wid, { waypoints: newWps }).catch(console.error); } } this._flashSaved(); } }; return; } const wire = this.canvas.wireData.get(id); const hasWaypoints = (wire?.waypoints?.length ?? 0) > 0; const items = [ { action: "copystyle", icon: "⎘", label: "Copy Style" }, ...(this._copiedWireStyle ? [{ action: "pastestyle", icon: "⎗", label: "Paste Style" }] : []), ...(wire?.label ? [{ action: "selectnet", icon: "⋈", label: `Select net "${wire.label}"` }] : []), "---", { action: "autoroute", icon: "⤢", label: "Auto-route" }, { action: "straighten", icon: "⤡", label: "Straighten wire" }, ...(hasWaypoints ? [{ action: "clearwp", icon: "⌀", label: "Clear waypoints" }] : []), "---", { action: "del", icon: "✕", label: "Delete wire", danger: true }, ]; this._openCtx(x, y, items); this._ctxMenu().onclick = async (e) => { const action = e.target.closest(".ctx-item")?.dataset.action; this._closeCtx(); if (!action) return; if (action === "del") this.deleteSelected(); if (action === "autoroute" && wire) { const newWps = this.canvas.autoRouteWire(id); if (newWps !== null) { api.wires.update(id, { waypoints: newWps }).catch(console.error); this._flashSaved(); } } if ((action === "clearwp" || action === "straighten") && wire) { wire.waypoints = []; this.canvas.updateWire(wire); api.wires.update(id, { waypoints: [] }).catch(console.error); this._flashSaved(); } if (action === "copystyle" && wire) { this._copiedWireStyle = { color_primary: wire.color_primary, color_stripe: wire.color_stripe, gauge: wire.gauge, twisted_pair: wire.twisted_pair, twist_pitch: wire.twist_pitch, shielded: wire.shielded, show_size_label: wire.show_size_label, }; } if (action === "pastestyle" && wire && this._copiedWireStyle) { Object.assign(wire, this._copiedWireStyle); this.canvas.updateWire(wire); await api.wires.update(id, this._copiedWireStyle).catch(console.error); if (this.canvas.selectedId === id) this._showWireProps(wire); this._flashSaved(); } if (action === "selectnet" && wire?.label) { this.canvas.highlightNet(wire.label); } }; } _onMultiWireSelected(ids) { // No panel update needed for multi-wire; the count could be shown in status bar } _setRouteMode(mode) { this.canvas.setRouteMode(mode); document.querySelectorAll(".route-btn").forEach(b => b.classList.remove("active")); document.getElementById(`btn-route-${mode}`)?.classList.add("active"); } _bindKeyboard() { document.addEventListener("keydown", (e) => { if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) return; if (e.key === "Delete" || e.key === "Backspace") this.deleteSelected(); if (e.key === "Escape") { this.setMode("select"); this._closeCtx(); } if (e.key === "w") this.setMode("wire"); if (e.key === "s") this.setMode("select"); if (e.key === "f" && !e.shiftKey) this.canvas.fitView(); if (e.key === "F" && e.shiftKey) this.canvas.zoomToSelection(); if (e.key === "g") { const btn = document.getElementById("btn-snap"); const en = !this.canvas.snapEnabled; this.canvas.toggleSnap(en); btn?.classList.toggle("active", en); } if (e.key === "h") { const btn = document.getElementById("btn-harness"); const en = !this.canvas.harnessMode; this.canvas.setHarnessMode(en); btn?.classList.toggle("active", en); } if ((e.ctrlKey || e.metaKey) && e.key === "c" && this.canvas.selectedType === "device") { e.preventDefault(); this.copySelected(); } if ((e.ctrlKey || e.metaKey) && e.key === "v") { e.preventDefault(); this.pasteClipboard(); } if ((e.ctrlKey || e.metaKey) && e.key === "d") { e.preventDefault(); this.duplicateSelected(); } if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); this._undo(); } // Arrow key nudge if (["ArrowLeft","ArrowRight","ArrowUp","ArrowDown"].includes(e.key)) { const ids = [...this.canvas.selectedDeviceIds]; if (!ids.length) return; e.preventDefault(); const step = e.shiftKey ? 10 : 1; const dx = e.key === "ArrowLeft" ? -step : e.key === "ArrowRight" ? step : 0; const dy = e.key === "ArrowUp" ? -step : e.key === "ArrowDown" ? step : 0; ids.forEach(id => { const g = this.canvas.deviceNodes.get(id); const d = this.canvas.deviceData.get(id); if (!g || !d) return; d.x += dx; d.y += dy; g.x(d.x); g.y(d.y); this.canvas._redrawWiresFor(id); }); this.canvas.deviceLayer.batchDraw(); clearTimeout(this._nudgeSaveTimer); this._nudgeSaveTimer = setTimeout(() => { ids.forEach(id => { const d = this.canvas.deviceData.get(id); if (d) this._savePosForCurrentView(id, d.x, d.y); }); this._flashSaved(); }, 300); } }); } // ── Properties panel ────────────────────────────────────────────────────────── _bindProps() { this._onPropChange("prop-reference", (v) => this._patchDevice({ reference: v }, true)); this._onPropChange("prop-label", (v) => this._patchDevice({ label: v }, true)); this._onPropChange("prop-partnumber", (v) => this._patchDeviceProp("partNumber", v)); this._onPropChange("prop-manufacturer", (v) => this._patchDeviceProp("manufacturer", v)); this._onPropChange("prop-fontsize", (v) => this._patchDeviceProp("fontSize", Math.max(6, Math.min(72, parseInt(v) || 12)), true)); this._onPropChange("wire-label", (v) => this._patchWire({ label: v })); this._onPropChange("wire-gauge", (v) => this._patchWire({ gauge: v })); this._onPropChange("wire-length", (v) => this._patchWire({ length: v ? parseFloat(v) : null }, true)); this._onPropChange("wire-unit", (v) => this._patchWire({ length_unit: v }, true)); this._onPropChange("wire-notes", (v) => this._patchWire({ notes: v })); document.getElementById("wire-color")?.addEventListener("input", (e) => this._patchWire({ color_primary: e.target.value }, true)); document.getElementById("wire-stripe-enabled")?.addEventListener("change", (e) => { const picker = document.getElementById("wire-stripe"); picker.disabled = !e.target.checked; picker.style.opacity = e.target.checked ? "1" : "0.3"; this._patchWire({ color_stripe: e.target.checked ? picker.value : null }, true); }); document.getElementById("wire-stripe")?.addEventListener("input", (e) => { const isTwisted = document.getElementById("wire-twisted")?.checked; if (isTwisted || document.getElementById("wire-stripe-enabled")?.checked) { this._patchWire({ color_stripe: e.target.value }, true); } }); document.getElementById("btn-octopart-search")?.addEventListener("click", () => this._octopartSearch()); document.getElementById("group-fill-color")?.addEventListener("input", async (e) => { if (this.canvas.selectedType !== "device") return; const d = this.canvas.deviceData.get(this.canvas.selectedId); if (!d || d.device_type !== "group") return; d.properties = { ...d.properties, fillColor: e.target.value }; await api.devices.update(d.id, { properties: d.properties }).catch(console.error); this.canvas.updateDevice(d); this._flashSaved(); }); document.getElementById("group-fill-opacity")?.addEventListener("input", async (e) => { if (this.canvas.selectedType !== "device") return; const d = this.canvas.deviceData.get(this.canvas.selectedId); if (!d || d.device_type !== "group") return; const opacity = parseFloat(e.target.value); document.getElementById("group-fill-opacity-val").textContent = Math.round(opacity * 100) + "%"; d.properties = { ...d.properties, fillOpacity: opacity }; await api.devices.update(d.id, { properties: d.properties }).catch(console.error); this.canvas.updateDevice(d); this._flashSaved(); }); document.getElementById("cable-jacket-color")?.addEventListener("input", async (e) => { if (this.canvas.selectedType !== "device") return; const d = this.canvas.deviceData.get(this.canvas.selectedId); if (!d || d.device_type !== "cable") return; d.properties = { ...d.properties, jacketColor: e.target.value }; await api.devices.update(d.id, { properties: d.properties }).catch(console.error); this.canvas.updateDevice(d); this._flashSaved(); }); document.getElementById("cable-sleeve-length")?.addEventListener("input", async (e) => { if (this.canvas.selectedType !== "device") return; const d = this.canvas.deviceData.get(this.canvas.selectedId); if (!d || d.device_type !== "cable") return; const val = parseInt(e.target.value); document.getElementById("cable-sleeve-length-val").textContent = val + " px"; d.properties = { ...d.properties, sleeveLength: val }; await api.devices.update(d.id, { properties: d.properties }).catch(console.error); this.canvas.updateDevice(d); this._flashSaved(); }); document.getElementById("prop-add-conductor")?.addEventListener("click", () => { if (this.canvas.selectedType !== "device") return; const d = this.canvas.deviceData.get(this.canvas.selectedId); if (d?.device_type === "cable") this._addConductor(d); }); document.getElementById("wire-twisted")?.addEventListener("change", (e) => { const pitchEl = document.getElementById("wire-twist-pitch"); pitchEl.disabled = !e.target.checked; pitchEl.style.opacity = e.target.checked ? "1" : "0.3"; const updates = { twisted_pair: e.target.checked }; if (e.target.checked) { const stripeEl = document.getElementById("wire-stripe"); if (!document.getElementById("wire-stripe-enabled").checked) { stripeEl.value = "#cccccc"; updates.color_stripe = "#cccccc"; } } this._patchWire(updates, true); this._updateWireTwistedUI(e.target.checked); }); document.getElementById("wire-twist-pitch")?.addEventListener("change", (e) => { this._patchWire({ twist_pitch: Math.max(8, Math.min(64, parseFloat(e.target.value) || 16)) }, true); }); document.getElementById("wire-shielded")?.addEventListener("change", (e) => { this._patchWire({ shielded: e.target.checked }, true); }); document.getElementById("wire-show-size-label")?.addEventListener("change", (e) => { this._patchWire({ show_size_label: e.target.checked }, true); }); document.getElementById("wire-bundle-select")?.addEventListener("change", (e) => { if (this._updatingBundleDropdown) return; const bundleId = e.target.value ? parseInt(e.target.value) : null; this._patchWire({ bundle_id: bundleId }, true); this._refreshBundleEditPanel(bundleId); }); document.getElementById("wire-bundle-new")?.addEventListener("click", async () => { if (!this.diagramId) return; try { const b = await api.bundles.create({ diagram_id: this.diagramId, label: "New Bundle", jacket_color: "#2a2a2a" }); this._bundles.push(b); this.canvas.loadBundles(this._bundles); this._refreshBundleDropdown(b.id); this._patchWire({ bundle_id: b.id }, true); this._refreshBundleEditPanel(b.id); } catch (e) { console.error("Create bundle failed:", e); } }); document.getElementById("wire-bundle-label")?.addEventListener("change", async (e) => { const bundleId = parseInt(document.getElementById("wire-bundle-select")?.value); if (!bundleId) return; try { const updated = await api.bundles.update(bundleId, { label: e.target.value }); const idx = this._bundles.findIndex(b => b.id === bundleId); if (idx >= 0) this._bundles[idx] = updated; this.canvas.loadBundles(this._bundles); this._refreshBundleDropdown(bundleId); } catch (ex) { console.error(ex); } }); document.getElementById("wire-bundle-color")?.addEventListener("input", async (e) => { const bundleId = parseInt(document.getElementById("wire-bundle-select")?.value); if (!bundleId) return; try { const updated = await api.bundles.update(bundleId, { jacket_color: e.target.value }); const idx = this._bundles.findIndex(b => b.id === bundleId); if (idx >= 0) this._bundles[idx] = updated; this.canvas.loadBundles(this._bundles); } catch (ex) { console.error(ex); } }); document.getElementById("wire-bundle-delete")?.addEventListener("click", async () => { const bundleId = parseInt(document.getElementById("wire-bundle-select")?.value); if (!bundleId || !confirm("Delete this bundle? Wires will be unassigned.")) return; try { await api.bundles.delete(bundleId); this._bundles = this._bundles.filter(b => b.id !== bundleId); this.canvas.wireData.forEach((w, wId) => { if (w.bundle_id === bundleId) { w.bundle_id = null; api.wires.update(wId, { bundle_id: null }).catch(console.error); } }); this.canvas.loadBundles(this._bundles); // also calls _renderBundles const selWire = this.canvas.wireData.get(this.canvas.selectedId); if (selWire?.bundle_id === null) this._refreshBundleDropdown(null); else this._refreshBundleDropdown(selWire?.bundle_id || null); this._refreshBundleEditPanel(selWire?.bundle_id || null); } catch (ex) { console.error(ex); } }); document.getElementById("prop-add-pin")?.addEventListener("click", () => { if (this.canvas.selectedType !== "device") return; const d = this.canvas.deviceData.get(this.canvas.selectedId); if (d) this._addDevicePin(d); }); document.getElementById("prop-save-connector")?.addEventListener("click", () => { if (this.canvas.selectedType !== "device") return; const d = this.canvas.deviceData.get(this.canvas.selectedId); if (d) this._saveDeviceAsConnector(d); }); const presetSelect = document.getElementById("wire-color-preset"); if (presetSelect) { WIRE_COLORS.forEach(c => { const opt = document.createElement("option"); opt.value = c.hex; opt.textContent = c.name; presetSelect.appendChild(opt); }); presetSelect.addEventListener("change", (e) => { if (!e.target.value) return; document.getElementById("wire-color").value = e.target.value; this._patchWire({ color_primary: e.target.value }, true); presetSelect.value = ""; }); } } _onPropChange(id, fn) { document.getElementById(id)?.addEventListener("change", (e) => fn(e.target.value)); } async _patchDevice(data, redraw = false) { if (this.canvas.selectedType !== "device") return; const id = this.canvas.selectedId; const d = this.canvas.deviceData.get(id); const oldData = d ? Object.fromEntries(Object.keys(data).map(k => [k, d[k]])) : {}; if (d) Object.assign(d, data); await api.devices.update(id, data).catch(console.error); this._flashSaved(); if (redraw && d) this.canvas.updateDevice(d); this._pushUndo(async () => { const cur = this.canvas.deviceData.get(id); if (cur) Object.assign(cur, oldData); await api.devices.update(id, oldData).catch(console.error); if (redraw && cur) this.canvas.updateDevice(cur); this._flashSaved(); }); } async _patchDeviceProp(key, value, redraw = false) { if (this.canvas.selectedType !== "device") return; const id = this.canvas.selectedId; const d = this.canvas.deviceData.get(id); if (!d) return; const oldProps = { ...(d.properties || {}) }; d.properties = { ...oldProps, [key]: value }; await api.devices.update(id, { properties: d.properties }).catch(console.error); this._flashSaved(); if (redraw) { this.canvas.updateDevice(d); this.canvas.selectDevice(id); } this._pushUndo(async () => { const cur = this.canvas.deviceData.get(id); if (cur) { cur.properties = oldProps; } await api.devices.update(id, { properties: oldProps }).catch(console.error); if (redraw) { if (cur) this.canvas.updateDevice(cur); this.canvas.selectDevice(id); } this._flashSaved(); }); } async _patchWire(data, redraw = false) { if (this.canvas.selectedType !== "wire") return; const id = this.canvas.selectedId; const w = this.canvas.wireData.get(id); const oldData = w ? Object.fromEntries(Object.keys(data).map(k => [k, w[k]])) : {}; if (w) { Object.assign(w, data); if (redraw) this.canvas.updateWire(w); } try { await api.wires.update(id, data); this._flashSaved(); this._pushUndo(async () => { const cur = this.canvas.wireData.get(id); if (cur) { Object.assign(cur, oldData); this.canvas.updateWire(cur); } await api.wires.update(id, oldData).catch(console.error); this._flashSaved(); }); } catch (e) { console.error("Wire save failed:", e); this._flashError(); } } // ── Diagram management ──────────────────────────────────────────────────────── async loadDiagramList() { const list = document.getElementById("diagram-list"); try { const diagrams = await api.diagrams.list(); if (!diagrams.length) { list.innerHTML = '

No diagrams yet

'; return; } list.innerHTML = ""; diagrams.forEach(d => { const el = document.createElement("div"); el.className = "diagram-item" + (d.id === this.diagramId ? " active" : ""); el.dataset.id = d.id; el.innerHTML = `${d.name} ${new Date(d.updated_at + "Z").toLocaleDateString()} `; el.querySelector(".di-name").addEventListener("click", () => this.openDiagram(d.id)); el.querySelector(".di-date").addEventListener("click", () => this.openDiagram(d.id)); el.querySelector(".di-dup-btn").addEventListener("click", async (e) => { e.stopPropagation(); try { const copy = await api.diagrams.duplicate(d.id); await this.loadDiagramList(); await this.openDiagram(copy.id); } catch (err) { alert("Duplicate failed: " + err.message); } }); el.querySelector(".di-del-btn").addEventListener("click", (e) => { e.stopPropagation(); this.deleteDiagram(d.id, d.name); }); list.appendChild(el); }); } catch { list.innerHTML = '

Failed to load

'; } } async deleteDiagram(id, name) { if (!confirm(`Delete "${name}" and all its contents? This cannot be undone.`)) return; try { await api.diagrams.delete(id); if (this.diagramId === id) { this.diagramId = null; this.currentViewId = null; this._views = []; localStorage.removeItem("lastDiagramId"); this.canvas.loadDiagram({ devices: [], wires: [] }); document.getElementById("diagram-name").value = ""; document.getElementById("canvas-placeholder").style.display = ""; document.getElementById("view-tab-bar").style.display = "none"; this._clearProps(); } await this.loadDiagramList(); } catch (e) { alert("Failed to delete: " + e.message); } } async newDiagram() { const name = prompt("Diagram name:", "New Diagram"); if (!name) return; try { const d = await api.diagrams.create({ name }); await this.loadDiagramList(); await this.openDiagram(d.id); } catch (e) { alert("Failed to create: " + e.message); } } async openDiagram(id) { try { const diagram = await api.diagrams.get(id); this.diagramId = id; this.currentViewId = null; localStorage.setItem("lastDiagramId", id); document.getElementById("diagram-name").value = diagram.name; document.getElementById("canvas-placeholder").style.display = "none"; this._bundles = diagram.wire_bundles || []; this.canvas.loadDiagram(diagram); this.canvas.loadBundles(this._bundles); if (diagram.devices?.length) setTimeout(() => this.canvas.fitView(), 50); this.setMode("select"); this._clearProps(); await this.loadDiagramList(); await this._loadViews(); } catch (e) { localStorage.removeItem("lastDiagramId"); console.error("Failed to open diagram:", e); } } // ── Views ───────────────────────────────────────────────────────────────────── async _loadViews() { if (!this.diagramId) return; try { this._views = await api.views.listForDiagram(this.diagramId); } catch { this._views = []; } this._renderViewTabs(); } _renderViewTabs() { const bar = document.getElementById("view-tab-bar"); if (!bar) return; // Remove all view tabs (keep add button) bar.querySelectorAll(".view-tab").forEach(t => t.remove()); const addBtn = document.getElementById("btn-add-view"); const mainTab = document.createElement("button"); mainTab.className = "view-tab" + (this.currentViewId === null ? " active" : ""); mainTab.dataset.viewId = "main"; mainTab.textContent = "Main"; mainTab.addEventListener("click", () => this._switchToView(null)); bar.insertBefore(mainTab, addBtn); this._views.forEach(v => { const tab = document.createElement("button"); tab.className = "view-tab" + (this.currentViewId === v.id ? " active" : ""); tab.dataset.viewId = v.id; const label = document.createElement("span"); label.textContent = v.name; tab.appendChild(label); const closeBtn = document.createElement("span"); closeBtn.textContent = "×"; closeBtn.className = "view-tab-close"; closeBtn.title = "Delete view"; closeBtn.addEventListener("click", async (e) => { e.stopPropagation(); if (!confirm(`Delete view "${v.name}"?`)) return; try { await api.views.delete(v.id); this._views = this._views.filter(x => x.id !== v.id); if (this.currentViewId === v.id) await this._switchToView(null); else this._renderViewTabs(); } catch (err) { alert("Failed to delete view: " + err.message); } }); tab.appendChild(closeBtn); tab.addEventListener("dblclick", () => this._renameView(v)); tab.addEventListener("click", () => this._switchToView(v.id)); tab.addEventListener("contextmenu", (e) => { e.preventDefault(); this._viewTabCtx(v, e.clientX, e.clientY); }); bar.insertBefore(tab, addBtn); }); bar.style.display = this.diagramId ? "" : "none"; if (!addBtn._bound) { addBtn._bound = true; addBtn.addEventListener("click", () => this._addView()); } } async _addView() { const name = prompt("View name (e.g. \"Curved\"):", "View " + (this._views.length + 1)); if (!name || !this.diagramId) return; const routeMode = prompt("Default routing (direct / curved / ortho):", "direct") || "direct"; try { const v = await api.views.create({ diagram_id: this.diagramId, name, route_mode: routeMode }); this._views.push(v); this._renderViewTabs(); await this._switchToView(v.id); } catch (e) { alert("Failed to create view: " + e.message); } } async _renameView(v) { const name = prompt("Rename view:", v.name); if (!name) return; try { const updated = await api.views.update(v.id, { name }); const idx = this._views.findIndex(x => x.id === v.id); if (idx >= 0) this._views[idx] = updated; this._renderViewTabs(); } catch (e) { alert("Failed to rename: " + e.message); } } _viewTabCtx(v, x, y) { this._openCtx(x, y, [ { action: "rename", icon: "✏", label: "Rename…" }, { action: "del", icon: "✕", label: "Delete view", danger: true }, ]); this._ctxMenu().onclick = async (e) => { const action = e.target.closest(".ctx-item")?.dataset.action; this._closeCtx(); if (action === "rename") this._renameView(v); if (action === "del") { if (!confirm(`Delete view "${v.name}"?`)) return; try { await api.views.delete(v.id); this._views = this._views.filter(x => x.id !== v.id); if (this.currentViewId === v.id) await this._switchToView(null); else this._renderViewTabs(); } catch (err) { alert("Failed to delete view: " + err.message); } } }; } async _switchToView(viewId) { this.currentViewId = viewId; const diagram = await api.diagrams.get(this.diagramId).catch(() => null); if (!diagram) return; if (viewId === null) { // Restore main layout — reload the diagram as-is this.canvas.loadDiagram(diagram); this.canvas.loadBundles(this._bundles); const routeBtn = document.querySelector(".route-btn.active"); // Keep current route mode } else { const view = this._views.find(v => v.id === viewId); const layout = await api.views.getLayout(viewId).catch(() => ({ device_positions: {}, wire_waypoints: {} })); this.canvas.loadDiagram(diagram); this.canvas.loadBundles(this._bundles); // Apply stored view positions const storedIds = new Set(Object.keys(layout.device_positions).map(Number)); Object.entries(layout.device_positions).forEach(([devIdStr, pos]) => { const devId = parseInt(devIdStr); const d = this.canvas.deviceData.get(devId); const g = this.canvas.deviceNodes.get(devId); if (d && g) { d.x = pos.x; d.y = pos.y; g.x(pos.x); g.y(pos.y); } }); // Snapshot any device NOT yet in this view's layout so main-tab moves // can never bleed through after this point. const toSnapshot = []; this.canvas.deviceData.forEach((d, devId) => { if (!storedIds.has(devId)) toSnapshot.push({ devId, x: d.x, y: d.y }); }); toSnapshot.forEach(({ devId, x, y }) => api.views.updateDevicePos(viewId, devId, { x, y }).catch(console.error) ); // Apply stored wire waypoints Object.entries(layout.wire_waypoints).forEach(([wireIdStr, wps]) => { const wireId = parseInt(wireIdStr); const w = this.canvas.wireData.get(wireId); if (w) { w.waypoints = wps; } }); this.canvas.deviceLayer.batchDraw(); this.canvas.wireLayer.batchDraw(); if (view?.route_mode) this._setRouteMode(view.route_mode); } this._renderViewTabs(); this.setMode("select"); this._clearProps(); } // ── DRC ─────────────────────────────────────────────────────────────────────── _runDrc() { if (!this.diagramId) return this._needDiagram(); const devices = [...this.canvas.deviceData.values()]; const wires = [...this.canvas.wireData.values()]; const categories = [ { key: "unconnected", label: "Unconnected Pins", icon: "🔴", items: [] }, { key: "dupref", label: "Duplicate References",icon: "🟠", items: [] }, { key: "nolength", label: "Missing Wire Length", icon: "🔵", items: [] }, { key: "nopn", label: "Missing Part Numbers", icon: "🟡", items: [] }, ]; const cat = Object.fromEntries(categories.map(c => [c.key, c.items])); // Unconnected pins const connectedPins = new Set(); wires.forEach(w => { if (w.from_device_id) connectedPins.add(`${w.from_device_id}::${w.from_pin}`); if (w.to_device_id) connectedPins.add(`${w.to_device_id}::${w.to_pin}`); }); devices.forEach(d => { if (d.device_type === "group") return; (d.pins || []).forEach(pin => { if (!connectedPins.has(`${d.id}::${pin.id}`)) cat.unconnected.push(`${d.reference || d.label || "Device"} — pin "${pin.name || pin.id}"`); }); }); // Duplicate references const refCount = new Map(); devices.forEach(d => { if (d.reference) refCount.set(d.reference, (refCount.get(d.reference) || 0) + 1); }); refCount.forEach((count, ref) => { if (count > 1) cat.dupref.push(`"${ref}" used on ${count} devices`); }); // Missing wire length wires.forEach(w => { if (!w.length) cat.nolength.push(`Wire ${w.label ? `"${w.label}"` : `ID ${w.id}`}`); }); // Missing part numbers devices.forEach(d => { if (d.device_type === "group") return; if (!d.properties?.partNumber) cat.nopn.push(`${d.reference || d.label || "Device"}`); }); // Render const modal = document.getElementById("drc-modal"); const results = document.getElementById("drc-results"); if (!modal || !results) return; const totalIssues = categories.reduce((s, c) => s + c.items.length, 0); if (!totalIssues) { results.innerHTML = `
✓ No issues found
`; } else { results.innerHTML = categories.map(c => { const count = c.items.length; const catId = `drc-cat-${c.key}`; const rows = c.items.map(msg => `
${msg}
` ).join(""); return `
${count ? rows : '
None
'}
`; }).join(""); results.querySelectorAll(".drc-toggle").forEach(cb => { cb.addEventListener("change", () => { const body = document.getElementById(cb.dataset.cat); if (body) body.style.display = cb.checked ? "" : "none"; }); }); } modal.style.display = "flex"; document.getElementById("drc-close")?.addEventListener("click", () => { modal.style.display = "none"; }, { once: true }); modal.addEventListener("click", (e) => { if (e.target === modal) modal.style.display = "none"; }, { once: true }); } // ── Actions ─────────────────────────────────────────────────────────────────── setMode(mode) { this.canvas.setMode(mode); document.querySelectorAll(".mode-btn").forEach(b => b.classList.remove("active")); document.getElementById(`btn-${mode}`)?.classList.add("active"); const ind = document.getElementById("mode-indicator"); if (ind) ind.textContent = mode === "wire" ? "Wire Mode — drag from a pin to another pin to connect (Esc to cancel)" : ""; } async addDevice(typeKey, wx, wy) { if (!this.diagramId) return this._needDiagram(); const def = buildDefaultDevice(typeKey, this.diagramId); if (!def) return; def.x = wx - def.width / 2; def.y = wy - def.height / 2; try { const device = await api.devices.create(def); this.canvas.addDevice(device); this._flashSaved(); this._pushUndo(async () => { this.canvas.removeDevice(device.id); await api.devices.delete(device.id).catch(console.error); this._flashSaved(); }); } catch (e) { console.error("Create device failed:", e); } } async addConnector(connId, wx, wy) { if (!this.diagramId) return this._needDiagram(); let def; if (connId.startsWith("custom_")) { const id = parseInt(connId.replace("custom_", "")); const conn = this._customConnectors.find(c => c.id === id); if (!conn) return; def = this._customConnectorToDevice(conn, this.diagramId); } else { def = connectorToDevice(connId, this.diagramId); } if (!def) return; def.x = wx - def.width / 2; def.y = wy - def.height / 2; try { const device = await api.devices.create(def); this.canvas.addDevice(device); this._flashSaved(); this._pushUndo(async () => { this.canvas.removeDevice(device.id); await api.devices.delete(device.id).catch(console.error); this._flashSaved(); }); } catch (e) { console.error("Create connector failed:", e); } } _customConnectorToDevice(conn, diagramId) { const pinCount = conn.pin_count; const w = 120; const h = Math.max(60, pinCount * 18 + 20); const pins = Array.from({ length: pinCount }, (_, i) => ({ id: `pin_${i + 1}`, name: (conn.pin_labels && conn.pin_labels[i]) || String(i + 1), side: "right", x_offset: w, y_offset: ((i + 1) / (pinCount + 1)) * h, })); return { diagram_id: diagramId, device_type: "connector", label: conn.name, reference: "", x: 200, y: 200, width: w, height: h, properties: { pinCount, orientation: "right", partNumber: conn.part_number || "", manufacturer: conn.manufacturer || "", customConnectorId: conn.id }, pins, }; } _conductorColorForPin(deviceId, pinId) { const d = this.canvas.deviceData.get(deviceId); if (d?.device_type !== "cable") return null; const m = pinId.match(/^[LR](\d+)$/); if (!m) return null; return d.properties?.conductors?.[parseInt(m[1]) - 1]?.color || null; } async createWire(fromDev, fromPin, toDev, toPin) { if (!this.diagramId) return; try { const autoColor = this._conductorColorForPin(fromDev, fromPin) || this._conductorColorForPin(toDev, toPin) || "#CC0000"; const wire = await api.wires.create({ diagram_id: this.diagramId, from_device_id: fromDev, from_pin: fromPin, to_device_id: toDev, to_pin: toPin, color_primary: autoColor, gauge: "18 AWG", }); this.canvas.addWire(wire); this.setMode("select"); this.canvas.selectWire(wire.id); this._showWireProps(wire); this._flashSaved(); this._pushUndo(async () => { this.canvas.clearSelection(); this._clearProps(); this.canvas.removeWire(wire.id); await api.wires.delete(wire.id).catch(console.error); this._flashSaved(); }); } catch (e) { console.error("Create wire failed:", e); } } async deleteSelected() { const deviceIds = [...this.canvas.selectedDeviceIds]; if (deviceIds.length) { // Snapshot data for undo before clearing const deviceSnaps = deviceIds.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id)))).filter(Boolean); const wireIds = new Set(); deviceIds.forEach(id => { this.canvas.wireData.forEach((w, wId) => { if (w.from_device_id === id || w.to_device_id === id) wireIds.add(wId); }); }); const wireSnaps = [...wireIds].map(wId => JSON.parse(JSON.stringify(this.canvas.wireData.get(wId)))).filter(Boolean); this.canvas.clearSelection(); this._clearProps(); try { for (const wId of wireIds) { await api.wires.delete(wId); this.canvas.removeWire(wId); } for (const id of deviceIds) { await api.devices.delete(id); this.canvas.removeDevice(id); } this._flashSaved(); this._pushUndo(async () => { for (const snap of deviceSnaps) { const restored = await api.devices.create(snap).catch(console.error); if (restored) this.canvas.addDevice(restored); } for (const snap of wireSnaps) { const restored = await api.wires.create(snap).catch(console.error); if (restored) this.canvas.addWire(restored); } this._flashSaved(); }); } catch (e) { console.error("Delete failed:", e); } return; } if (this.canvas.selectedType === "wire" && this.canvas.selectedId) { const id = this.canvas.selectedId; const wireSnap = JSON.parse(JSON.stringify(this.canvas.wireData.get(id))); this.canvas.clearSelection(); this._clearProps(); try { await api.wires.delete(id); this.canvas.removeWire(id); this._flashSaved(); this._pushUndo(async () => { const restored = await api.wires.create(wireSnap).catch(console.error); if (restored) { this.canvas.addWire(restored); this._flashSaved(); } }); } catch (e) { console.error("Delete failed:", e); } } } copySelected() { const ids = [...this.canvas.selectedDeviceIds]; if (!ids.length) return; this._clipboard = ids.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id)))).filter(Boolean); this._pasteCount = 0; } async pasteClipboard() { if (!this._clipboard?.length || !this.diagramId) return; this._pasteCount++; await this._cloneDevices(this._clipboard, this._pasteCount * 30); } async duplicateSelected() { const ids = [...this.canvas.selectedDeviceIds]; if (!ids.length || !this.diagramId) return; const sources = ids.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id)))).filter(Boolean); this._clipboard = sources; this._pasteCount = 1; await this._cloneDevices(sources, 30); } async _cloneDevices(sources, offset) { this.canvas.clearSelection(); const clonedIds = []; for (const source of sources) { const def = { diagram_id: this.diagramId, device_type: source.device_type, label: source.label, reference: "", x: source.x + offset, y: source.y + offset, width: source.width, height: source.height, properties: JSON.parse(JSON.stringify(source.properties || {})), pins: JSON.parse(JSON.stringify(source.pins || [])), }; try { const device = await api.devices.create(def); this.canvas.addDevice(device); this.canvas.addToSelection(device.id); clonedIds.push(device.id); } catch (e) { console.error("Clone device failed:", e); } } this._flashSaved(); this._pushUndo(async () => { this.canvas.clearSelection(); this._clearProps(); for (const cid of clonedIds) { this.canvas.removeDevice(cid); await api.devices.delete(cid).catch(console.error); } this._flashSaved(); }); if (this.canvas.selectedDeviceIds.size === 1) { const [id] = this.canvas.selectedDeviceIds; this.canvas.selectedId = id; this._showDeviceProps(this.canvas.deviceData.get(id)); } else if (this.canvas.selectedDeviceIds.size > 1) { this._showMultiProps(this.canvas.selectedDeviceIds.size); } } _export(type) { if (!this.diagramId) return this._needDiagram(); document.getElementById("export-menu")?.classList.remove("open"); const url = type === "bom" ? api.export.bomUrl(this.diagramId) : type === "assembly" ? api.export.assemblyUrl(this.diagramId) : type === "formboard" ? api.export.formboardUrl(this.diagramId) : api.export.jsonUrl(this.diagramId); window.open(url, "_blank"); } _needDiagram() { alert("Please open or create a diagram first."); } // ── Octopart / Nexar part search ────────────────────────────────────────────── async _checkOctopartStatus() { try { const { configured } = await api.octopart.status(); this._octopartReady = configured; } catch { this._octopartReady = false; } } async _octopartSearch() { const pn = document.getElementById("prop-partnumber")?.value?.trim(); const resultsEl = document.getElementById("octopart-results"); const statusEl = document.getElementById("octopart-status-msg"); const btn = document.getElementById("btn-octopart-search"); if (!resultsEl || !statusEl) return; resultsEl.style.display = "none"; resultsEl.innerHTML = ""; statusEl.style.display = "none"; if (this._octopartReady === false) { statusEl.textContent = "Nexar API not configured — set NEXAR_CLIENT_ID and NEXAR_CLIENT_SECRET on the server."; statusEl.style.display = ""; return; } if (!pn) { statusEl.textContent = "Enter a part number first."; statusEl.style.display = ""; return; } btn?.classList.add("searching"); try { const results = await api.octopart.search(pn, 8); btn?.classList.remove("searching"); if (!results.length) { resultsEl.innerHTML = `
No parts found for "${pn}"
`; resultsEl.style.display = ""; return; } resultsEl.innerHTML = results.map((r, i) => `
${r.mpn}
${r.manufacturer || "—"}
${r.description ? `
${r.description}
` : ""} ${r.datasheet_url ? `
📄 datasheet available
` : ""}
`).join(""); resultsEl.style.display = ""; resultsEl.querySelectorAll(".op-result").forEach((el, i) => { el.addEventListener("click", () => { this._applyOctopartResult(results[i]); resultsEl.style.display = "none"; }); }); } catch (e) { btn?.classList.remove("searching"); statusEl.textContent = `Search failed: ${e.message}`; statusEl.style.display = ""; } } async _applyOctopartResult(result) { if (this.canvas.selectedType !== "device") return; const id = this.canvas.selectedId; const d = this.canvas.deviceData.get(id); if (!d) return; document.getElementById("prop-partnumber").value = result.mpn; document.getElementById("prop-manufacturer").value = result.manufacturer || ""; d.properties = { ...(d.properties || {}), partNumber: result.mpn, manufacturer: result.manufacturer || "", description: result.description || "", datasheetUrl: result.datasheet_url || "", }; await api.devices.update(id, { properties: d.properties, reference: d.reference, }).catch(console.error); this._showDatasheetLink(result.datasheet_url); this._flashSaved(); } _showDatasheetLink(url) { const row = document.getElementById("prop-datasheet-row"); const link = document.getElementById("prop-datasheet-link"); if (!row || !link) return; if (url) { link.href = url; row.style.display = ""; } else { row.style.display = "none"; } } // ── Cable management ────────────────────────────────────────────────────────── _refreshGroupSection(device) { const section = document.getElementById("group-section"); if (!section) return; if (device.device_type !== "group") { section.style.display = "none"; return; } section.style.display = ""; const color = device.properties?.fillColor || "#2828a0"; const opacity = device.properties?.fillOpacity ?? 0.15; document.getElementById("group-fill-color").value = color; document.getElementById("group-fill-opacity").value = opacity; document.getElementById("group-fill-opacity-val").textContent = Math.round(opacity * 100) + "%"; } _refreshCableSection(device) { const section = document.getElementById("cable-section"); if (!section) return; if (device.device_type !== "cable") { section.style.display = "none"; return; } section.style.display = ""; document.getElementById("cable-jacket-color").value = device.properties?.jacketColor || "#2a2a2a"; const sl = device.properties?.sleeveLength ?? 60; document.getElementById("cable-sleeve-length").value = sl; document.getElementById("cable-sleeve-length-val").textContent = sl + " px"; const tbody = document.getElementById("conductor-table-body"); tbody.innerHTML = ""; (device.properties?.conductors || []).forEach((cond, i) => { const tr = document.createElement("tr"); tr.innerHTML = `${i + 1} `; tr.querySelector(".conductor-name-input").addEventListener("change", async (e) => { const d = this.canvas.deviceData.get(device.id); if (!d?.properties?.conductors) return; const idx = parseInt(e.target.dataset.idx); d.properties.conductors[idx].name = e.target.value; const lp = d.pins.find(p => p.id === `L${idx + 1}`); const rp = d.pins.find(p => p.id === `R${idx + 1}`); if (lp) lp.name = e.target.value; if (rp) rp.name = e.target.value; await api.devices.update(d.id, { properties: d.properties, pins: d.pins }).catch(console.error); this.canvas.updateDevice(d); this._flashSaved(); }); tr.querySelector(".conductor-color-input").addEventListener("input", async (e) => { const d = this.canvas.deviceData.get(device.id); if (!d?.properties?.conductors) return; d.properties.conductors[parseInt(e.target.dataset.idx)].color = e.target.value; await api.devices.update(d.id, { properties: d.properties }).catch(console.error); this.canvas.updateDevice(d); this._flashSaved(); }); tr.querySelector(".conductor-del-btn").addEventListener("click", () => { const d = this.canvas.deviceData.get(device.id); if (d) this._removeConductor(d, parseInt(tr.querySelector(".conductor-del-btn").dataset.idx)); }); tbody.appendChild(tr); }); } async _addConductor(device) { const d = this.canvas.deviceData.get(device.id); if (!d) return; const conductors = [...(d.properties?.conductors || [])]; const color = CABLE_DEFAULT_COLORS[conductors.length % CABLE_DEFAULT_COLORS.length]; const i = conductors.length; conductors.push({ name: String(i + 1), color }); const newH = conductors.length * 24 + 40; d.properties = { ...d.properties, conductors, conductorCount: conductors.length }; d.height = newH; d.pins.push( { id: `L${i + 1}`, name: String(i + 1), side: "left", x_offset: 0, y_offset: 26 + i * 24 + 12 }, { id: `R${i + 1}`, name: String(i + 1), side: "right", x_offset: d.width, y_offset: 26 + i * 24 + 12 }, ); await api.devices.update(d.id, { properties: d.properties, height: newH, pins: d.pins }).catch(console.error); this.canvas.updateDevice(d); this.canvas.selectDevice(d.id); this._flashSaved(); } async _removeConductor(device, idx) { const d = this.canvas.deviceData.get(device.id); if (!d) return; const conductors = [...(d.properties?.conductors || [])]; if (conductors.length <= 1) { alert("Cable must have at least one conductor."); return; } conductors.splice(idx, 1); d.pins = d.pins.filter(p => p.id !== `L${idx + 1}` && p.id !== `R${idx + 1}`); // Renumber remaining pins let li = 0, ri = 0; d.pins = d.pins.map(p => { if (p.side === "left") { const n = li++; return { ...p, id: `L${n+1}`, name: conductors[n]?.name || String(n+1), y_offset: 26 + n * 24 + 12 }; } if (p.side === "right") { const n = ri++; return { ...p, id: `R${n+1}`, name: conductors[n]?.name || String(n+1), y_offset: 26 + n * 24 + 12 }; } return p; }); const newH = conductors.length * 24 + 40; d.height = newH; d.properties = { ...d.properties, conductors, conductorCount: conductors.length }; await api.devices.update(d.id, { properties: d.properties, height: newH, pins: d.pins }).catch(console.error); this.canvas.updateDevice(d); this.canvas.selectDevice(d.id); this._flashSaved(); } _updateWireTwistedUI(isTwisted) { document.getElementById("wire-stripe-lbl").textContent = isTwisted ? "Wire 2 Color" : "Stripe Color"; document.getElementById("wire-stripe-toggle-lbl").style.display = isTwisted ? "none" : ""; const picker = document.getElementById("wire-stripe"); if (isTwisted) { picker.disabled = false; picker.style.opacity = "1"; } else { const hasStripe = document.getElementById("wire-stripe-enabled").checked; picker.disabled = !hasStripe; picker.style.opacity = hasStripe ? "1" : "0.3"; } } // ── Properties panel ────────────────────────────────────────────────────────── _showDeviceProps(device) { this._setPropVisible("props-device"); document.getElementById("prop-type").textContent = DEVICE_TYPES[device.device_type]?.label || device.device_type; document.getElementById("prop-reference").value = device.reference || ""; document.getElementById("prop-label").value = device.label || ""; document.getElementById("prop-partnumber").value = device.properties?.partNumber || ""; document.getElementById("prop-manufacturer").value = device.properties?.manufacturer || ""; document.getElementById("prop-fontsize").value = device.properties?.fontSize || 12; const isGroup = device.device_type === "group"; if (!isGroup) this._showDatasheetLink(device.properties?.datasheetUrl || ""); else { const dr = document.getElementById("prop-datasheet-row"); if (dr) dr.style.display = "none"; } document.getElementById("prop-save-connector").style.display = isGroup ? "none" : ""; document.getElementById("prop-add-pin").style.display = isGroup ? "none" : ""; document.getElementById("pin-table-body").closest(".prop-row").style.display = isGroup ? "none" : ""; // Reset any open search results const res = document.getElementById("octopart-results"); const msg = document.getElementById("octopart-status-msg"); if (res) { res.style.display = "none"; res.innerHTML = ""; } if (msg) msg.style.display = "none"; this._refreshCableSection(device); this._refreshGroupSection(device); const tbody = document.getElementById("pin-table-body"); tbody.innerHTML = ""; (device.pins || []).forEach(pin => { const tr = document.createElement("tr"); const sides = ["left", "right", "top", "bottom"]; const sideOpts = sides.map(s => ``).join(""); tr.innerHTML = `${pin.id} `; tr.querySelector(".pin-side-select").addEventListener("change", async (e) => { const d = this.canvas.deviceData.get(device.id); if (!d) return; const p = d.pins.find(p => p.id === pin.id); if (!p) return; p.side = e.target.value; this._recalcPinOffsets(d.pins, d.width, d.height); await api.devices.update(d.id, { pins: d.pins }).catch(console.error); this.canvas.updateDevice(d); this._showDeviceProps(d); this._flashSaved(); }); tr.querySelector(".pin-del-btn").addEventListener("click", () => { const d = this.canvas.deviceData.get(device.id); if (d) this._removeDevicePin(d, pin.id); }); tbody.appendChild(tr); }); tbody.querySelectorAll(".pin-input").forEach(inp => { inp.addEventListener("change", async (e) => { const devId = parseInt(e.target.dataset.dev); const pinId = e.target.dataset.pin; const d = this.canvas.deviceData.get(devId); if (!d) return; const pin = d.pins.find(p => p.id === pinId); if (pin) pin.name = e.target.value; await api.devices.update(devId, { pins: d.pins }).catch(console.error); this.canvas.updateDevice(d); this._flashSaved(); }); }); } _showWireProps(wire) { this._setPropVisible("props-wire"); const fd = this.canvas.deviceData.get(wire.from_device_id); const td = this.canvas.deviceData.get(wire.to_device_id); document.getElementById("wire-from").textContent = fd ? `${fd.reference || fd.label} : ${wire.from_pin}` : "—"; document.getElementById("wire-to").textContent = td ? `${td.reference || td.label} : ${wire.to_pin}` : "—"; document.getElementById("wire-label").value = wire.label || ""; document.getElementById("wire-color").value = wire.color_primary || "#cc0000"; const hasStripe = !!wire.color_stripe; document.getElementById("wire-stripe-enabled").checked = hasStripe; document.getElementById("wire-stripe").value = wire.color_stripe || "#cccccc"; document.getElementById("wire-stripe").disabled = !hasStripe; document.getElementById("wire-stripe").style.opacity = hasStripe ? "1" : "0.3"; document.getElementById("wire-gauge").value = wire.gauge || "18 AWG"; document.getElementById("wire-length").value = wire.length != null ? wire.length : ""; document.getElementById("wire-unit").value = wire.length_unit || "in"; const isTwisted = !!wire.twisted_pair; document.getElementById("wire-twisted").checked = isTwisted; document.getElementById("wire-twist-pitch").value = wire.twist_pitch || 16; document.getElementById("wire-twist-pitch").disabled = !isTwisted; document.getElementById("wire-twist-pitch").style.opacity = isTwisted ? "1" : "0.3"; document.getElementById("wire-shielded").checked = !!wire.shielded; document.getElementById("wire-show-size-label").checked = !!wire.show_size_label; document.getElementById("wire-notes").value = wire.notes || ""; this._updateWireTwistedUI(isTwisted); this._refreshBundleDropdown(wire.bundle_id || null); this._refreshBundleEditPanel(wire.bundle_id || null); } _refreshBundleDropdown(selectedId) { const sel = document.getElementById("wire-bundle-select"); if (!sel) return; this._updatingBundleDropdown = true; sel.innerHTML = ''; this._bundles.forEach(b => { const opt = document.createElement("option"); opt.value = b.id; opt.textContent = b.label || `Bundle #${b.id}`; if (b.id === selectedId) opt.selected = true; sel.appendChild(opt); }); this._updatingBundleDropdown = false; } _refreshBundleEditPanel(bundleId) { const panel = document.getElementById("wire-bundle-edit"); if (!panel) return; if (!bundleId) { panel.style.display = "none"; return; } const b = this._bundles.find(x => x.id === bundleId); if (!b) { panel.style.display = "none"; return; } panel.style.display = ""; document.getElementById("wire-bundle-label").value = b.label || ""; document.getElementById("wire-bundle-color").value = b.jacket_color || "#2a2a2a"; } _clearProps() { this._setPropVisible("props-empty"); } _showMultiProps(count) { this._setPropVisible("props-multi"); const el = document.getElementById("props-multi-count"); if (el) el.textContent = `${count} items selected`; } _setPropVisible(activeId) { ["props-empty", "props-device", "props-wire", "props-multi"].forEach(id => { const el = document.getElementById(id); if (el) el.style.display = id === activeId ? "" : "none"; }); } // ── Pin management ──────────────────────────────────────────────────────────── _recalcPinOffsets(pins, width, height) { ["left", "right", "top", "bottom"].forEach(side => { const sp = pins.filter(p => p.side === side); sp.forEach((p, i) => { const t = (i + 1) / (sp.length + 1); if (side === "left") { p.x_offset = 0; p.y_offset = t * height; } if (side === "right") { p.x_offset = width; p.y_offset = t * height; } if (side === "top") { p.x_offset = t * width; p.y_offset = 0; } if (side === "bottom") { p.x_offset = t * width; p.y_offset = height; } }); }); } async _addDevicePin(device) { const pins = device.pins || []; const lastSide = pins[pins.length - 1]?.side || "right"; const maxNum = pins.reduce((m, p) => Math.max(m, parseInt(p.id.replace(/\D/g, "")) || 0), 0); const newPins = [...pins, { id: `pin_${maxNum + 1}`, name: String(maxNum + 1), side: lastSide, x_offset: 0, y_offset: 0 }]; const newHeight = Math.max(device.height || 60, newPins.length * 18 + 20); this._recalcPinOffsets(newPins, device.width, newHeight); device.pins = newPins; device.height = newHeight; await api.devices.update(device.id, { pins: newPins, height: newHeight }).catch(console.error); this.canvas.updateDevice(device); this._showDeviceProps(device); this._flashSaved(); } async _removeDevicePin(device, pinId) { const connectedWireIds = []; this.canvas.wireData.forEach((w, wId) => { if ((w.from_device_id === device.id && w.from_pin === pinId) || (w.to_device_id === device.id && w.to_pin === pinId)) connectedWireIds.push(wId); }); if (connectedWireIds.length && !confirm(`This pin has ${connectedWireIds.length} connected wire(s). Remove pin and its wires?`)) return; for (const wId of connectedWireIds) { await api.wires.delete(wId).catch(console.error); this.canvas.removeWire(wId); } const newPins = (device.pins || []).filter(p => p.id !== pinId); const newHeight = Math.max(60, newPins.length * 18 + 20); this._recalcPinOffsets(newPins, device.width, newHeight); device.pins = newPins; device.height = newHeight; await api.devices.update(device.id, { pins: newPins, height: newHeight }).catch(console.error); this.canvas.updateDevice(device); this._showDeviceProps(device); this._flashSaved(); } _saveDeviceAsConnector(device) { const pins = device.pins || []; const connData = { name: device.label || "From Canvas", manufacturer: device.properties?.manufacturer || "", part_number: device.properties?.partNumber || "", category: "Custom", description: "", pin_count: pins.length, pin_labels: pins.map(p => p.name || ""), }; const customConnId = device.properties?.customConnectorId; if (customConnId != null) { const existing = this._customConnectors.find(c => c.id === customConnId); if (existing) { this.openConnectorModal({ ...existing, ...connData }); return; } } this.openConnectorModal(connData); } // ── Git version control ─────────────────────────────────────────────────── async _openGitModal() { const modal = document.getElementById("git-modal"); if (!modal) return; modal.style.display = "flex"; // Wire close button once const closeBtn = document.getElementById("git-close"); if (!closeBtn._gitBound) { closeBtn._gitBound = true; closeBtn.addEventListener("click", () => { modal.style.display = "none"; }); modal.addEventListener("click", (e) => { if (e.target === modal) modal.style.display = "none"; }); } // Wire action buttons once const commitBtn = document.getElementById("btn-git-commit"); if (!commitBtn._gitBound) { commitBtn._gitBound = true; commitBtn.addEventListener("click", () => this._gitCommit()); document.getElementById("btn-git-push").addEventListener("click", () => this._gitPush()); } await this._gitRefresh(); } async _gitRefresh() { await Promise.all([this._gitLoadStatus(), this._gitLoadLog()]); } async _gitLoadStatus() { const badge = document.getElementById("git-status-badge"); if (!badge) return; try { const s = await api.git.status(); const ahead = s.commits_ahead > 0 ? ` · ${s.commits_ahead} ahead` : ""; const dirty = s.is_clean ? "clean" : `${s.changed_count} changed`; badge.textContent = `${s.branch} ${dirty}${ahead}`; badge.className = "git-badge " + (s.is_clean ? "git-clean" : "git-dirty"); } catch { badge.textContent = "git unavailable"; badge.className = "git-badge"; } } async _gitLoadLog() { const list = document.getElementById("git-log-list"); if (!list) return; list.innerHTML = '
Loading…
'; try { const entries = await api.git.log(); if (!entries.length) { list.innerHTML = '
No commits yet
'; return; } list.innerHTML = ""; entries.forEach(e => { const row = document.createElement("div"); row.className = "git-log-row"; row.innerHTML = ` ${e.short} ${this._esc(e.message)} ${e.date.slice(0, 10)} `; row.querySelector(".git-restore-btn").addEventListener("click", () => this._gitRestoreFlow(e)); list.appendChild(row); }); } catch { list.innerHTML = '
Failed to load log
'; } } async _gitCommit() { const msg = document.getElementById("git-commit-msg")?.value?.trim(); if (!msg) { alert("Enter a commit message first."); return; } const currentOnly = document.getElementById("git-scope-current")?.checked; if (currentOnly && !this.diagramId) { alert("No diagram is currently open."); return; } const diagramId = currentOnly ? this.diagramId : null; const status = document.getElementById("git-op-status"); status.textContent = "Committing…"; status.style.color = "#aabb88"; try { const r = await api.git.commit(msg, diagramId); if (r.status === "nothing_to_commit") { status.textContent = "Nothing to commit — diagram unchanged."; status.style.color = "#8899bb"; } else { status.textContent = "✓ Committed: " + r.output.split("\n")[0]; status.style.color = "#88cc88"; document.getElementById("git-commit-msg").value = ""; await this._gitRefresh(); } } catch (e) { status.textContent = "✗ " + (e.message || "Commit failed"); status.style.color = "#e06c6c"; } } async _gitPush() { const status = document.getElementById("git-op-status"); status.textContent = "Pushing…"; status.style.color = "#aabb88"; try { const r = await api.git.push(); status.textContent = "✓ Pushed. " + (r.output || "").split("\n")[0]; status.style.color = "#88cc88"; await this._gitLoadStatus(); } catch (e) { status.textContent = "✗ Push failed: " + (e.message || "unknown error"); status.style.color = "#e06c6c"; } } async _gitRestoreFlow(entry) { const status = document.getElementById("git-op-status"); status.textContent = `Loading files from ${entry.short}…`; status.style.color = "#aabb88"; try { const { files } = await api.git.history(entry.hash); if (!files.length) { status.textContent = "No diagrams in that commit."; status.style.color = "#8899bb"; return; } // Build a small picker inline const pick = files.map((f, i) => `${i + 1}. ${f.replace("diagrams/", "").replace(/\.json$/, "").replace(/^\d{4}_/, "").replace(/_/g, " ")}`).join("\n"); const choice = prompt(`Diagrams in commit ${entry.short} — "${entry.message}"\n\nEnter number to restore:\n${pick}`); if (!choice) { status.textContent = ""; return; } const idx = parseInt(choice) - 1; if (isNaN(idx) || idx < 0 || idx >= files.length) { status.textContent = "Invalid selection."; return; } const filepath = files[idx]; status.textContent = `Restoring ${filepath}…`; status.style.color = "#aabb88"; const r = await api.git.restore(entry.hash, filepath); status.textContent = `✓ Restored as "${r.name}" (id ${r.id})`; status.style.color = "#88cc88"; await this.loadDiagramList(); } catch (e) { status.textContent = "✗ Restore failed: " + (e.message || "unknown"); status.style.color = "#e06c6c"; } } _esc(s) { return String(s).replace(/&/g,"&").replace(//g,">"); } } window.addEventListener("DOMContentLoaded", () => { window.app = new WiringApp(); });