Files
Wiring-Designer/frontend/js/app.js
T
Kyle 82d8570767 Add formboard harness layout designer
Separate read-only canvas for physically laying out wire harnesses.
Syncs connectors from the main diagram (one-way), no changes flow back.

Backend:
- FormboardLayout, FormboardNode, FormboardSegment models
- /api/formboard/ router: get/create layout, sync connectors from diagram,
  full CRUD for nodes and segments

Frontend (formboard.js):
- Konva canvas: drag nodes, click-to-connect segments, zoom/pan
- Node types: Connector (blue rect, synced from diagram), Branch (green
  circle, manual), Splice/joint (orange diamond, manual)
- Segments scale thickness by wire count; fitting overlays for open,
  split loom, conduit, heat shrink, tape wrap
- Right-click context menu for delete on nodes/segments

App integration:
- "⊞ Formboard" tab in view-tab-bar (distinct color, independent of views)
- Sub-toolbar: Select / Branch / Splice / Connect / Sync / Fit / Delete
- Right panel swaps to formboard props when active:
  - Node: label, notes, linked device info
  - Segment: fitting type, fitting color, label, length, wire checklist
    (checkboxes for every wire in the main diagram)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 07:18:43 -04:00

2420 lines
106 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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._fb = null; // FormboardCanvas instance (lazy)
this._fbActive = false;
this._buildDeviceLibrary();
this._bindToolbar();
this._bindKeyboard();
this._bindProps();
this._bindTabs();
this._bindConnectorModal();
this._bindLibrarySearch();
this._bindFormboard();
this.loadDiagramList()
.then(() => this._autoOpenLast())
.then(() => this.loadCustomConnectors())
.then(() => this._checkOctopartStatus());
}
// ── View-aware position/waypoint saving ───────────────────────────────────────
_savePosForCurrentView(id, x, y) {
if (this.currentViewId) {
api.views.updateDevicePos(this.currentViewId, id, { x, y }).catch(console.error);
} else {
api.devices.update(id, { x, y }).catch(console.error);
}
}
_saveWaypointsForCurrentView(wireId, waypoints) {
if (this.currentViewId) {
api.views.updateWireWaypoints(this.currentViewId, wireId, { waypoints }).catch(console.error);
} else {
api.wires.update(wireId, { waypoints }).catch(console.error);
}
}
// ── Auto-reopen & saved indicator ────────────────────────────────────────────
_autoOpenLast() {
const lastId = localStorage.getItem("lastDiagramId");
if (lastId) return this.openDiagram(parseInt(lastId));
}
_flashSaved() {
clearTimeout(this._savedTimer);
this._savedTimer = setTimeout(() => {
const el = document.getElementById("saved-indicator");
if (!el) return;
el.textContent = "✓ Saved";
el.style.color = "";
el.classList.add("show");
setTimeout(() => el.classList.remove("show"), 1400);
}, 600);
}
_flashError() {
clearTimeout(this._savedTimer);
const el = document.getElementById("saved-indicator");
if (!el) return;
el.textContent = "✗ Save failed";
el.style.color = "#e06c6c";
el.classList.add("show");
this._savedTimer = setTimeout(() => {
el.classList.remove("show");
el.style.color = "";
}, 3000);
}
// ── Align & Distribute ────────────────────────────────────────────────────────
async _alignDevices(edge) {
const ids = [...this.canvas.selectedIds];
if (ids.length < 2) return;
const boxes = ids.map(id => {
const d = this.canvas.deviceData.get(id);
return { id, x: d.x, y: d.y, w: d.width || 80, h: d.height || 60 };
});
let target;
if (edge === "left") target = Math.min(...boxes.map(b => b.x));
if (edge === "right") target = Math.max(...boxes.map(b => b.x + b.w));
if (edge === "top") target = Math.min(...boxes.map(b => b.y));
if (edge === "bottom") target = Math.max(...boxes.map(b => b.y + b.h));
for (const b of boxes) {
const d = this.canvas.deviceData.get(b.id);
if (edge === "left") d.x = target;
if (edge === "right") d.x = target - b.w;
if (edge === "top") d.y = target;
if (edge === "bottom") d.y = target - b.h;
this.canvas.updateDevice(d);
await api.devices.update(b.id, { x: d.x, y: d.y }).catch(console.error);
}
this._flashSaved();
}
async _distributeDevices(axis) {
const ids = [...this.canvas.selectedIds];
if (ids.length < 3) return;
const boxes = ids.map(id => {
const d = this.canvas.deviceData.get(id);
return { id, x: d.x, y: d.y, w: d.width || 80, h: d.height || 60 };
});
if (axis === "h") {
boxes.sort((a, b) => a.x - b.x);
const totalW = boxes.reduce((s, b) => s + b.w, 0);
const gap = (boxes[boxes.length - 1].x + boxes[boxes.length - 1].w - boxes[0].x - totalW) / (boxes.length - 1);
let cursor = boxes[0].x + boxes[0].w;
for (let i = 1; i < boxes.length - 1; i++) {
const d = this.canvas.deviceData.get(boxes[i].id);
d.x = Math.round(cursor + gap);
this.canvas.updateDevice(d);
await api.devices.update(boxes[i].id, { x: d.x }).catch(console.error);
cursor = d.x + boxes[i].w;
}
} else {
boxes.sort((a, b) => a.y - b.y);
const totalH = boxes.reduce((s, b) => s + b.h, 0);
const gap = (boxes[boxes.length - 1].y + boxes[boxes.length - 1].h - boxes[0].y - totalH) / (boxes.length - 1);
let cursor = boxes[0].y + boxes[0].h;
for (let i = 1; i < boxes.length - 1; i++) {
const d = this.canvas.deviceData.get(boxes[i].id);
d.y = Math.round(cursor + gap);
this.canvas.updateDevice(d);
await api.devices.update(boxes[i].id, { y: d.y }).catch(console.error);
cursor = d.y + boxes[i].h;
}
}
this._flashSaved();
}
// ── Undo ──────────────────────────────────────────────────────────────────────
_pushUndo(fn) {
this._undoStack.push(fn);
if (this._undoStack.length > 50) this._undoStack.shift();
}
async _undo() {
const fn = this._undoStack.pop();
if (fn) await fn();
}
_pushMoveUndo(movedIds) {
if (!this._preDrag) return;
const { positions, waypoints } = this._preDrag;
this._preDrag = null;
this._pushUndo(async () => {
for (const [id, { x, y }] of positions) {
const g = this.canvas.deviceNodes.get(id);
const d = this.canvas.deviceData.get(id);
if (g) { g.x(x); g.y(y); }
if (d) { d.x = x; d.y = y; }
this._savePosForCurrentView(id, x, y);
this.canvas._redrawWiresFor(id);
}
for (const [wId, wps] of waypoints) {
const wire = this.canvas.wireData.get(wId);
if (wire) {
wire.waypoints = wps.map(wp => ({ ...wp }));
this._saveWaypointsForCurrentView(wId, wire.waypoints);
}
}
this.canvas.deviceLayer.batchDraw();
this.canvas.wireLayer.batchDraw();
this._flashSaved();
});
}
// ── Device library ────────────────────────────────────────────────────────────
_buildDeviceLibrary() {
const container = document.getElementById("device-library");
container.innerHTML = "";
Object.entries(DEVICE_TYPES).forEach(([key, type]) => {
const item = document.createElement("div");
item.className = "lib-item";
item.draggable = true;
item.dataset.type = key;
item.innerHTML = `<span class="lib-icon">${type.icon}</span><span class="lib-label">${type.label}</span>`;
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 = '<option value="">All Categories</option>';
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 = '<p class="muted small" style="padding:8px 10px">No connectors match</p>';
}
}
_makeCustomItem(conn) {
const item = document.createElement("div");
item.className = "lib-conn-item";
item.draggable = true;
item.title = conn.description || conn.name;
item.innerHTML = `
<div class="conn-item-body">
<div class="conn-name">${conn.name} <span class="conn-custom-badge">custom</span></div>
<div class="conn-meta">${conn.manufacturer || "—"} · ${conn.pin_count}p${conn.part_number ? " · " + conn.part_number : ""}</div>
</div>
<button class="conn-edit-btn" title="Edit connector">✏</button>`;
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 = `
<div class="conn-item-body">
<div class="conn-name">${conn.name}</div>
<div class="conn-meta">${conn.manufacturer} · ${conn.pinCount}p · ${conn.partNumber}</div>
</div>`;
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 = `<span class="pin-idx">${i + 1}</span>
<input type="text" class="pin-label-input" placeholder="Pin ${i + 1}" value="${(existingLabels[i] || "").replace(/"/g, "&quot;")}">`;
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 164"); 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 `<div class="ctx-sep"></div>`;
return `<div class="ctx-item${item.danger ? " danger" : ""}" data-action="${item.action}">
${item.icon ? `<span>${item.icon}</span>` : ""}<span>${item.label}</span>
</div>`;
}).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 = '<p class="muted small" style="padding:6px 10px">No diagrams yet</p>';
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 = `<span class="di-name">${d.name}</span>
<span class="di-date">${new Date(d.updated_at + "Z").toLocaleDateString()}</span>
<button class="di-dup-btn" title="Duplicate diagram">⊕</button>
<button class="di-del-btn" title="Delete diagram">×</button>`;
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 = '<p class="muted small" style="padding:6px 10px">Failed to load</p>'; }
}
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 = `<div style="color:#6ec97b;padding:16px;text-align:center;font-size:14px">✓ No issues found</div>`;
} else {
results.innerHTML = categories.map(c => {
const count = c.items.length;
const catId = `drc-cat-${c.key}`;
const rows = c.items.map(msg =>
`<div class="drc-row">${msg}</div>`
).join("");
return `
<div class="drc-category">
<label class="drc-cat-header">
<input type="checkbox" class="drc-toggle" data-cat="${catId}" ${count ? "checked" : "disabled"}>
<span class="drc-cat-icon">${c.icon}</span>
<span class="drc-cat-label">${c.label}</span>
<span class="drc-cat-count">${count}</span>
</label>
<div id="${catId}" class="drc-cat-body" ${!count ? 'style="display:none"' : ""}>
${count ? rows : '<div class="drc-row drc-none">None</div>'}
</div>
</div>`;
}).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 = `<div class="op-no-results">No parts found for "${pn}"</div>`;
resultsEl.style.display = "";
return;
}
resultsEl.innerHTML = results.map((r, i) => `
<div class="op-result" data-idx="${i}">
<div class="op-mpn">${r.mpn}</div>
<div class="op-mfr">${r.manufacturer || "—"}</div>
${r.description ? `<div class="op-desc">${r.description}</div>` : ""}
${r.datasheet_url ? `<div class="op-ds">📄 datasheet available</div>` : ""}
</div>`).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 = `<td class="pin-side">${i + 1}</td>
<td><input type="text" class="conductor-name-input pin-input" data-idx="${i}" value="${(cond.name || String(i+1)).replace(/"/g, "&quot;")}"></td>
<td><input type="color" class="conductor-color-input" data-idx="${i}" value="${cond.color || "#888888"}" style="width:40px;height:22px;padding:1px;cursor:pointer"></td>
<td><button class="conductor-del-btn pin-del-btn" data-idx="${i}" title="Remove">×</button></td>`;
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 => `<option value="${s}"${pin.side === s ? " selected" : ""}>${s[0].toUpperCase()}</option>`).join("");
tr.innerHTML = `<td class="pin-side">${pin.id}</td>
<td><input class="pin-input" data-dev="${device.id}" data-pin="${pin.id}" value="${(pin.name || "").replace(/"/g, "&quot;")}"></td>
<td><select class="pin-side-select">${sideOpts}</select></td>
<td><button class="pin-del-btn" title="Remove pin">×</button></td>`;
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 = '<option value="">— none —</option>';
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());
// History scope toggle
const btnDiagram = document.getElementById("btn-git-log-diagram");
const btnAll = document.getElementById("btn-git-log-all");
btnDiagram.addEventListener("click", () => {
btnDiagram.classList.add("active"); btnAll.classList.remove("active");
this._gitLoadLog();
});
btnAll.addEventListener("click", () => {
btnAll.classList.add("active"); btnDiagram.classList.remove("active");
this._gitLoadLog();
});
}
// Default scope: "this diagram" if one is open, else "all"
const hasDiagram = !!this.diagramId;
document.getElementById("btn-git-log-diagram").classList.toggle("active", hasDiagram);
document.getElementById("btn-git-log-all").classList.toggle("active", !hasDiagram);
document.getElementById("btn-git-log-diagram").disabled = !hasDiagram;
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;
const diagramScope = document.getElementById("btn-git-log-diagram")?.classList.contains("active");
const diagramId = diagramScope && this.diagramId ? this.diagramId : null;
const title = document.getElementById("git-log-title");
if (title) title.textContent = diagramId ? "History — this diagram" : "History — all commits";
list.innerHTML = '<div style="color:#556;padding:4px">Loading…</div>';
try {
const entries = await api.git.log(diagramId);
if (!entries.length) {
list.innerHTML = '<div style="color:#556;padding:4px">No commits yet</div>';
return;
}
list.innerHTML = "";
entries.forEach(e => {
const row = document.createElement("div");
row.className = "git-log-row";
row.innerHTML = `
<span class="git-hash">${e.short}</span>
<span class="git-msg">${this._esc(e.message)}</span>
<span class="git-date">${e.date.slice(0, 10)}</span>
<button class="git-restore-btn" data-hash="${e.hash}" title="Restore a diagram from this commit">↩ Restore</button>
`;
row.querySelector(".git-restore-btn").addEventListener("click", () => this._gitRestoreFlow(e));
list.appendChild(row);
});
} catch {
list.innerHTML = '<div style="color:#e06c6c;padding:4px">Failed to load log</div>';
}
}
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
// ── Formboard ─────────────────────────────────────────────────────────────
_bindFormboard() {
document.getElementById("btn-formboard-tab")?.addEventListener("click", () => {
if (this._fbActive) this._closeFormboard(); else this._openFormboard();
});
const on = (id, fn) => document.getElementById(id)?.addEventListener("click", fn);
on("fb-btn-select", () => this._fbSetMode("select"));
on("fb-btn-branch", () => this._fbSetMode("add-branch"));
on("fb-btn-splice", () => this._fbSetMode("add-splice"));
on("fb-btn-connect", () => this._fbSetMode("connect"));
on("fb-btn-fit", () => this._fb?.fitView());
on("fb-btn-sync", () => this._fbSync());
on("fb-btn-del", () => this._fbDeleteSelected());
// Node props
document.getElementById("fb-node-label")?.addEventListener("input", (e) => {
if (!this._fb || this._fb.selectedType !== "node") return;
clearTimeout(this._fbSaveTimer);
this._fbSaveTimer = setTimeout(() => this._fbPatchNode({ label: e.target.value }), 400);
});
document.getElementById("fb-node-notes")?.addEventListener("input", (e) => {
if (!this._fb || this._fb.selectedType !== "node") return;
clearTimeout(this._fbSaveTimer);
this._fbSaveTimer = setTimeout(() => this._fbPatchNode({ notes: e.target.value }), 400);
});
// Segment props
document.getElementById("fb-seg-fitting")?.addEventListener("change", (e) => {
if (!this._fb || this._fb.selectedType !== "segment") return;
const showColor = ["heat_shrink", "tape"].includes(e.target.value);
document.getElementById("fb-seg-color-row").style.display = showColor ? "" : "none";
this._fbPatchSeg({ fitting_type: e.target.value });
});
document.getElementById("fb-seg-fitting-color")?.addEventListener("input", (e) => {
if (!this._fb || this._fb.selectedType !== "segment") return;
clearTimeout(this._fbColorTimer);
this._fbColorTimer = setTimeout(() => this._fbPatchSeg({ fitting_color: e.target.value }), 200);
});
document.getElementById("fb-seg-label")?.addEventListener("input", (e) => {
if (!this._fb || this._fb.selectedType !== "segment") return;
clearTimeout(this._fbSaveTimer);
this._fbSaveTimer = setTimeout(() => this._fbPatchSeg({ label: e.target.value }), 400);
});
document.getElementById("fb-seg-length")?.addEventListener("input", (e) => {
if (!this._fb || this._fb.selectedType !== "segment") return;
clearTimeout(this._fbSaveTimer);
this._fbSaveTimer = setTimeout(() => this._fbPatchSeg({ length_mm: parseFloat(e.target.value) || null }), 400);
});
// Keyboard delete in formboard
document.addEventListener("keydown", (e) => {
if (!this._fbActive) return;
if (["INPUT","TEXTAREA","SELECT"].includes(e.target.tagName)) return;
if (e.key === "Delete" || e.key === "Backspace") this._fbDeleteSelected();
});
}
async _openFormboard() {
if (!this.diagramId) return this._needDiagram();
this._fbActive = true;
// Tab styling
document.getElementById("btn-formboard-tab")?.classList.add("active");
document.querySelectorAll(".view-tab:not(.fb-tab)").forEach(t => t.classList.remove("active"));
// Show formboard, hide main canvas
document.getElementById("canvas-container").style.display = "none";
document.getElementById("fb-container").style.display = "flex";
document.getElementById("fb-toolbar").style.display = "flex";
// Swap properties panel
document.getElementById("props-empty").style.display = "none";
document.getElementById("props-device").style.display = "none";
document.getElementById("props-wire").style.display = "none";
document.getElementById("props-formboard").style.display = "";
// Create canvas if needed
if (!this._fb) {
this._fb = new FormboardCanvas("fb-canvas", {
onNodeSelected: (n) => this._fbShowNodeProps(n),
onSegmentSelected: (s) => this._fbShowSegProps(s),
onSelectionCleared:() => this._fbClearProps(),
onNodeMoved: (id, x, y) => api.formboard.updateNode(id, { x, y }).catch(console.error),
onAddNode: async (data) => {
const n = await api.formboard.createNode(this.diagramId, data).catch(console.error);
if (n) this._fb.addNode(n);
},
onSegmentDrawn: async (fromId, toId) => {
const s = await api.formboard.createSegment(this.diagramId, { from_node_id: fromId, to_node_id: toId }).catch(console.error);
if (s) this._fb.addSegment(s);
},
onNodeCtx: (id, x, y) => this._fbNodeCtx(id, x, y),
onSegCtx: (id, x, y) => this._fbSegCtx(id, x, y),
});
}
// Load data
const data = await api.formboard.get(this.diagramId).catch(() => null);
if (data) this._fb.load(data);
if (data?.nodes?.length) this._fb.fitView();
}
_closeFormboard() {
this._fbActive = false;
document.getElementById("btn-formboard-tab")?.classList.remove("active");
document.getElementById("canvas-container").style.display = "";
document.getElementById("fb-container").style.display = "none";
document.getElementById("fb-toolbar").style.display = "none";
document.getElementById("props-formboard").style.display = "none";
document.getElementById("props-empty").style.display = "";
this._renderViewTabs(); // restore active view tab highlight
}
_fbSetMode(mode) {
this._fb?.setMode(mode);
document.querySelectorAll(".fb-mode-btn").forEach(b => b.classList.remove("active"));
document.getElementById(`fb-btn-${mode === "add-branch" ? "branch" : mode === "add-splice" ? "splice" : mode === "connect" ? "connect" : "select"}`)?.classList.add("active");
}
async _fbSync() {
if (!this.diagramId) return;
const data = await api.formboard.sync(this.diagramId).catch(e => { alert("Sync failed: " + e.message); return null; });
if (!data) return;
this._fb?.load(data);
if (data.added > 0) this._fb?.fitView();
}
async _fbDeleteSelected() {
if (!this._fb || this._fb.selectedId === null) return;
const { selectedId, selectedType } = this._fb;
if (selectedType === "node") {
await api.formboard.deleteNode(selectedId).catch(console.error);
// Also remove segments that referenced this node from the canvas
[...this._fb.segData.values()]
.filter(s => s.from_node_id === selectedId || s.to_node_id === selectedId)
.forEach(s => this._fb.deleteSegment(s.id));
this._fb.deleteNode(selectedId);
} else if (selectedType === "segment") {
await api.formboard.deleteSegment(selectedId).catch(console.error);
this._fb.deleteSegment(selectedId);
}
this._fbClearProps();
}
async _fbPatchNode(data) {
if (!this._fb || this._fb.selectedType !== "node") return;
const id = this._fb.selectedId;
const updated = await api.formboard.updateNode(id, data).catch(console.error);
if (updated) this._fb.refreshNodeLabel(updated);
}
async _fbPatchSeg(data) {
if (!this._fb || this._fb.selectedType !== "segment") return;
const id = this._fb.selectedId;
const updated = await api.formboard.updateSegment(id, data).catch(console.error);
if (updated) this._fb.refreshSegment(updated);
}
_fbShowNodeProps(node) {
document.getElementById("fb-props-empty").style.display = "none";
document.getElementById("fb-seg-props").style.display = "none";
document.getElementById("fb-node-props").style.display = "";
const typeLabels = { connector: "Connector (synced from diagram)", branch: "Branch point", splice: "Splice / joint" };
document.getElementById("fb-node-type-badge").textContent = typeLabels[node.node_type] || node.node_type;
document.getElementById("fb-node-label").value = node.label || "";
document.getElementById("fb-node-notes").value = node.notes || "";
const infoEl = document.getElementById("fb-connector-info");
if (node.node_type === "connector" && node.device_id) {
const dev = this.canvas.deviceData.get(node.device_id);
document.getElementById("fb-connector-dev-label").textContent =
dev ? `${dev.reference || ""} ${dev.label || ""}`.trim() || `Device ${dev.id}` : `Device ID ${node.device_id}`;
infoEl.style.display = "";
} else {
infoEl.style.display = "none";
}
}
_fbShowSegProps(seg) {
document.getElementById("fb-props-empty").style.display = "none";
document.getElementById("fb-node-props").style.display = "none";
document.getElementById("fb-seg-props").style.display = "";
document.getElementById("fb-seg-fitting").value = seg.fitting_type || "open";
document.getElementById("fb-seg-fitting-color").value = seg.fitting_color || "#884444";
document.getElementById("fb-seg-label").value = seg.label || "";
document.getElementById("fb-seg-length").value = seg.length_mm || "";
const showColor = ["heat_shrink", "tape"].includes(seg.fitting_type || "");
document.getElementById("fb-seg-color-row").style.display = showColor ? "" : "none";
// Build wire checklist from main diagram
const wireList = document.getElementById("fb-wire-list");
const assignedIds = new Set(seg.wire_ids || []);
wireList.innerHTML = "";
this.canvas.wireData.forEach(w => {
const row = document.createElement("label");
row.style.cssText = "display:flex;align-items:center;gap:6px;padding:3px 4px;border-radius:3px;cursor:pointer;";
row.innerHTML = `
<input type="checkbox" ${assignedIds.has(w.id) ? "checked" : ""}>
<span style="width:12px;height:12px;border-radius:2px;background:${w.color_primary || "#cc0000"};flex-shrink:0"></span>
<span style="color:#bbc">${w.label || "(unlabeled)"}</span>
<span style="color:#556;margin-left:auto">${w.gauge || ""}</span>
`;
row.querySelector("input").addEventListener("change", () => {
const checked = [];
wireList.querySelectorAll("label[data-wire-id]").forEach(lbl => {
if (lbl.querySelector("input")?.checked) checked.push(parseInt(lbl.dataset.wireId));
});
this._fbPatchSeg({ wire_ids: checked });
});
row.dataset.wireId = w.id;
wireList.appendChild(row);
});
}
_fbClearProps() {
document.getElementById("fb-props-empty").style.display = "";
document.getElementById("fb-node-props").style.display = "none";
document.getElementById("fb-seg-props").style.display = "none";
}
_fbNodeCtx(id, x, y) {
this._openCtx(x, y, [
{ action: "del", icon: "✕", label: "Delete node + segments", danger: true },
]);
this._ctxMenu().onclick = async (e) => {
const action = e.target.closest(".ctx-item")?.dataset.action;
this._closeCtx();
if (action === "del") { this._fb.selectNode(id); await this._fbDeleteSelected(); }
};
}
_fbSegCtx(id, x, y) {
this._openCtx(x, y, [
{ action: "del", icon: "✕", label: "Delete segment", danger: true },
]);
this._ctxMenu().onclick = async (e) => {
const action = e.target.closest(".ctx-item")?.dataset.action;
this._closeCtx();
if (action === "del") { this._fb.selectSegment(id); await this._fbDeleteSelected(); }
};
}
}
window.addEventListener("DOMContentLoaded", () => { window.app = new WiringApp(); });