class WiringApp { constructor() { this.diagramId = null; 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) => { api.devices.update(id, { x, y }).catch(console.error); 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) => { api.wires.update(id, { waypoints: wps }).catch(console.error); 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), 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()); } // ── 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; } await api.devices.update(id, { x, y }).catch(console.error); 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 })); await api.wires.update(wId, { waypoints: wire.waypoints }).catch(console.error); } } 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-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 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: "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 === "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); } }; } _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(); } }); } // ── 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-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; localStorage.removeItem("lastDiagramId"); this.canvas.loadDiagram({ devices: [], wires: [] }); document.getElementById("diagram-name").value = ""; document.getElementById("canvas-placeholder").style.display = ""; 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; 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(); } catch (e) { localStorage.removeItem("lastDiagramId"); console.error("Failed to open diagram:", e); } } // ── 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); } } window.addEventListener("DOMContentLoaded", () => { window.app = new WiringApp(); });