class WiringApp {
constructor() {
this.diagramId = null;
this._savedTimer = null;
this._customConnectors = []; // loaded from backend
this._editingConnId = null; // id of custom connector being edited in modal
this._clipboard = null; // copied device data
this._pasteCount = 0; // paste offset counter
this._undoStack = []; // array of async undo functions
this._preDrag = null; // snapshot captured at dragstart for undo
this._octopartReady = null; // null=unknown, true/false after status check
this._bundles = []; // wire bundles for current diagram
this._updatingBundleDropdown = false;
this._copiedWireStyle = null; // clipboard for wire style copy/paste
this.canvas = new DiagramCanvas("canvas-container", {
onDeviceSelected: (d) => this._showDeviceProps(d),
onWireSelected: (w) => this._showWireProps(w),
onSelectionCleared: () => this._clearProps(),
onMultipleSelected: (count) => this._showMultiProps(count),
onDeviceMoved: (id, x, y) => { api.devices.update(id, { x, y }).catch(console.error); this._flashSaved(); },
onDeviceDropped: (type, wx, wy) => this.addDevice(type, wx, wy),
onConnectorDropped: (connId, wx, wy) => this.addConnector(connId, wx, wy),
onWireCreated: (fDev, fPin, tDev, tPin) => this.createWire(fDev, fPin, tDev, tPin),
onWaypointsChanged: (id, wps) => { api.wires.update(id, { waypoints: wps }).catch(console.error); this._flashSaved(); },
onDragStarted: (snap) => { this._preDrag = snap; },
onDragEnded: (ids) => { this._pushMoveUndo(ids); },
onDeviceContextMenu: (id, x, y) => this._showCtxDevice(id, x, y),
onWireContextMenu: (id, x, y) => this._showCtxWire(id, x, y),
onDeviceResized: async (id, x, y, w, h) => {
const device = this.canvas.deviceData.get(id);
if (!device) return;
this._recalcPinOffsets(device.pins || [], w, h);
await api.devices.update(id, { x, y, width: w, height: h, pins: device.pins }).catch(console.error);
this.canvas.updateDevice(device);
this.canvas.selectDevice(id);
this._flashSaved();
},
});
this._buildDeviceLibrary();
this._bindToolbar();
this._bindKeyboard();
this._bindProps();
this._bindTabs();
this._bindConnectorModal();
this._bindLibrarySearch();
this.loadDiagramList()
.then(() => this._autoOpenLast())
.then(() => this.loadCustomConnectors())
.then(() => this._checkOctopartStatus());
}
// ── Auto-reopen & saved indicator ────────────────────────────────────────────
_autoOpenLast() {
const lastId = localStorage.getItem("lastDiagramId");
if (lastId) return this.openDiagram(parseInt(lastId));
}
_flashSaved() {
clearTimeout(this._savedTimer);
this._savedTimer = setTimeout(() => {
const el = document.getElementById("saved-indicator");
if (!el) return;
el.textContent = "✓ Saved";
el.style.color = "";
el.classList.add("show");
setTimeout(() => el.classList.remove("show"), 1400);
}, 600);
}
_flashError() {
clearTimeout(this._savedTimer);
const el = document.getElementById("saved-indicator");
if (!el) return;
el.textContent = "✗ Save failed";
el.style.color = "#e06c6c";
el.classList.add("show");
this._savedTimer = setTimeout(() => {
el.classList.remove("show");
el.style.color = "";
}, 3000);
}
// ── Align & Distribute ────────────────────────────────────────────────────────
async _alignDevices(edge) {
const ids = [...this.canvas.selectedIds];
if (ids.length < 2) return;
const boxes = ids.map(id => {
const d = this.canvas.deviceData.get(id);
return { id, x: d.x, y: d.y, w: d.width || 80, h: d.height || 60 };
});
let target;
if (edge === "left") target = Math.min(...boxes.map(b => b.x));
if (edge === "right") target = Math.max(...boxes.map(b => b.x + b.w));
if (edge === "top") target = Math.min(...boxes.map(b => b.y));
if (edge === "bottom") target = Math.max(...boxes.map(b => b.y + b.h));
for (const b of boxes) {
const d = this.canvas.deviceData.get(b.id);
if (edge === "left") d.x = target;
if (edge === "right") d.x = target - b.w;
if (edge === "top") d.y = target;
if (edge === "bottom") d.y = target - b.h;
this.canvas.updateDevice(d);
await api.devices.update(b.id, { x: d.x, y: d.y }).catch(console.error);
}
this._flashSaved();
}
async _distributeDevices(axis) {
const ids = [...this.canvas.selectedIds];
if (ids.length < 3) return;
const boxes = ids.map(id => {
const d = this.canvas.deviceData.get(id);
return { id, x: d.x, y: d.y, w: d.width || 80, h: d.height || 60 };
});
if (axis === "h") {
boxes.sort((a, b) => a.x - b.x);
const totalW = boxes.reduce((s, b) => s + b.w, 0);
const gap = (boxes[boxes.length - 1].x + boxes[boxes.length - 1].w - boxes[0].x - totalW) / (boxes.length - 1);
let cursor = boxes[0].x + boxes[0].w;
for (let i = 1; i < boxes.length - 1; i++) {
const d = this.canvas.deviceData.get(boxes[i].id);
d.x = Math.round(cursor + gap);
this.canvas.updateDevice(d);
await api.devices.update(boxes[i].id, { x: d.x }).catch(console.error);
cursor = d.x + boxes[i].w;
}
} else {
boxes.sort((a, b) => a.y - b.y);
const totalH = boxes.reduce((s, b) => s + b.h, 0);
const gap = (boxes[boxes.length - 1].y + boxes[boxes.length - 1].h - boxes[0].y - totalH) / (boxes.length - 1);
let cursor = boxes[0].y + boxes[0].h;
for (let i = 1; i < boxes.length - 1; i++) {
const d = this.canvas.deviceData.get(boxes[i].id);
d.y = Math.round(cursor + gap);
this.canvas.updateDevice(d);
await api.devices.update(boxes[i].id, { y: d.y }).catch(console.error);
cursor = d.y + boxes[i].h;
}
}
this._flashSaved();
}
// ── Undo ──────────────────────────────────────────────────────────────────────
_pushUndo(fn) {
this._undoStack.push(fn);
if (this._undoStack.length > 50) this._undoStack.shift();
}
async _undo() {
const fn = this._undoStack.pop();
if (fn) await fn();
}
_pushMoveUndo(movedIds) {
if (!this._preDrag) return;
const { positions, waypoints } = this._preDrag;
this._preDrag = null;
this._pushUndo(async () => {
for (const [id, { x, y }] of positions) {
const g = this.canvas.deviceNodes.get(id);
const d = this.canvas.deviceData.get(id);
if (g) { g.x(x); g.y(y); }
if (d) { d.x = x; d.y = y; }
await api.devices.update(id, { x, y }).catch(console.error);
this.canvas._redrawWiresFor(id);
}
for (const [wId, wps] of waypoints) {
const wire = this.canvas.wireData.get(wId);
if (wire) {
wire.waypoints = wps.map(wp => ({ ...wp }));
await api.wires.update(wId, { waypoints: wire.waypoints }).catch(console.error);
}
}
this.canvas.deviceLayer.batchDraw();
this.canvas.wireLayer.batchDraw();
this._flashSaved();
});
}
// ── Device library ────────────────────────────────────────────────────────────
_buildDeviceLibrary() {
const container = document.getElementById("device-library");
container.innerHTML = "";
Object.entries(DEVICE_TYPES).forEach(([key, type]) => {
const item = document.createElement("div");
item.className = "lib-item";
item.draggable = true;
item.dataset.type = key;
item.innerHTML = `${type.icon}${type.label}`;
item.title = type.description;
item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("device_type", key); e.dataTransfer.effectAllowed = "copy"; });
item.addEventListener("dblclick", () => { if (!this.diagramId) return this._needDiagram(); this.addDevice(key, 200 + Math.random() * 100, 150 + Math.random() * 100); });
container.appendChild(item);
});
}
// ── Connector library ─────────────────────────────────────────────────────────
async loadCustomConnectors() {
try { this._customConnectors = await api.connectors.list(); }
catch { this._customConnectors = []; }
this._filterConnectors();
}
_bindLibrarySearch() {
document.getElementById("lib-search")?.addEventListener("input", () => this._filterConnectors());
document.getElementById("lib-category")?.addEventListener("change", () => this._filterConnectors());
}
_buildConnectorLibrary() {
const catSelect = document.getElementById("lib-category");
catSelect.innerHTML = '';
const cats = new Set(Object.keys(CONNECTOR_LIBRARY));
this._customConnectors.forEach(c => cats.add(c.category || "Custom"));
cats.forEach(cat => {
const opt = document.createElement("option");
opt.value = cat; opt.textContent = cat;
catSelect.appendChild(opt);
});
}
_filterConnectors() {
this._buildConnectorLibrary(); // refresh category list
const query = (document.getElementById("lib-search")?.value || "").toLowerCase();
const catFilter = document.getElementById("lib-category")?.value || "";
const list = document.getElementById("lib-list");
list.innerHTML = "";
// ── Custom connectors (from DB) ──────────────────────────────────────────
const customs = this._customConnectors.filter(conn => {
if (catFilter && (conn.category || "Custom") !== catFilter) return false;
if (query && !`${conn.name} ${conn.manufacturer} ${conn.part_number} ${conn.description}`.toLowerCase().includes(query)) return false;
return true;
});
if (customs.length) {
const header = document.createElement("div");
header.className = "lib-section-header";
header.textContent = "Custom Connectors";
list.appendChild(header);
customs.forEach(conn => list.appendChild(this._makeCustomItem(conn)));
}
// ── Built-in library ─────────────────────────────────────────────────────
Object.entries(CONNECTOR_LIBRARY).forEach(([cat, conns]) => {
if (catFilter && cat !== catFilter) return;
const matched = conns.filter(conn => !query ||
`${conn.name} ${conn.manufacturer} ${conn.partNumber} ${conn.description}`.toLowerCase().includes(query));
if (!matched.length) return;
const header = document.createElement("div");
header.className = "lib-section-header";
header.textContent = cat;
list.appendChild(header);
matched.forEach(conn => list.appendChild(this._makeBuiltinItem(conn)));
});
if (!list.children.length) {
list.innerHTML = '
No connectors match
';
}
}
_makeCustomItem(conn) {
const item = document.createElement("div");
item.className = "lib-conn-item";
item.draggable = true;
item.title = conn.description || conn.name;
item.innerHTML = `
${conn.name} custom
${conn.manufacturer || "—"} · ${conn.pin_count}p${conn.part_number ? " · " + conn.part_number : ""}
`;
item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("connector_id", "custom_" + conn.id); e.dataTransfer.effectAllowed = "copy"; });
item.addEventListener("dblclick", (e) => { if (e.target.classList.contains("conn-edit-btn")) return; if (!this.diagramId) return this._needDiagram(); this.addConnector("custom_" + conn.id, 220, 160); });
item.querySelector(".conn-edit-btn").addEventListener("click", (e) => { e.stopPropagation(); this.openConnectorModal(conn); });
return item;
}
_makeBuiltinItem(conn) {
const item = document.createElement("div");
item.className = "lib-conn-item";
item.draggable = true;
item.title = conn.description || conn.name;
item.innerHTML = `
${conn.name}
${conn.manufacturer} · ${conn.pinCount}p · ${conn.partNumber}
`;
item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("connector_id", conn.id); e.dataTransfer.effectAllowed = "copy"; });
item.addEventListener("dblclick", () => { if (!this.diagramId) return this._needDiagram(); this.addConnector(conn.id, 220 + Math.random() * 80, 160 + Math.random() * 80); });
return item;
}
// ── Custom connector modal ────────────────────────────────────────────────────
_bindConnectorModal() {
document.getElementById("btn-new-connector")?.addEventListener("click", () => this.openConnectorModal(null));
document.getElementById("cm-save")?.addEventListener("click", () => this._saveConnectorModal());
document.getElementById("cm-delete")?.addEventListener("click", () => this._deleteConnectorModal());
document.getElementById("cm-cancel")?.addEventListener("click", () => this.closeConnectorModal());
// Close on backdrop click
document.getElementById("connector-modal")?.addEventListener("click", (e) => {
if (e.target.id === "connector-modal") this.closeConnectorModal();
});
// Escape key
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && document.getElementById("connector-modal")?.style.display !== "none") {
this.closeConnectorModal();
}
});
// Update pin labels when pin count changes
document.getElementById("cm-pincount")?.addEventListener("input", (e) => {
const count = Math.max(1, Math.min(64, parseInt(e.target.value) || 1));
const existing = [...document.querySelectorAll(".pin-label-input")].map(i => i.value);
this._buildPinLabelInputs(count, existing);
});
}
openConnectorModal(conn = null) {
this._editingConnId = conn?.id ?? null;
document.getElementById("cm-modal-title").textContent = conn ? "Edit Custom Connector" : "New Custom Connector";
document.getElementById("cm-name").value = conn?.name || "";
document.getElementById("cm-category").value = conn?.category || "Custom";
document.getElementById("cm-manufacturer").value = conn?.manufacturer || "";
document.getElementById("cm-partnumber").value = conn?.part_number || "";
document.getElementById("cm-description").value = conn?.description || "";
document.getElementById("cm-pincount").value = conn?.pin_count || 4;
document.getElementById("cm-delete").style.display = (conn?.id != null) ? "" : "none";
this._buildPinLabelInputs(conn?.pin_count || 4, conn?.pin_labels || []);
document.getElementById("connector-modal").style.display = "flex";
document.getElementById("cm-name").focus();
}
closeConnectorModal() {
document.getElementById("connector-modal").style.display = "none";
this._editingConnId = null;
}
_buildPinLabelInputs(count, existingLabels = []) {
const container = document.getElementById("cm-pin-labels");
container.innerHTML = "";
for (let i = 0; i < count; i++) {
const row = document.createElement("div");
row.className = "pin-label-row";
row.innerHTML = `${i + 1}
`;
container.appendChild(row);
}
}
async _saveConnectorModal() {
const name = document.getElementById("cm-name").value.trim();
const pinCount = parseInt(document.getElementById("cm-pincount").value);
if (!name) { document.getElementById("cm-name").focus(); return; }
if (!pinCount || pinCount < 1 || pinCount > 64) { alert("Pin count must be 1–64"); return; }
const data = {
name,
category: document.getElementById("cm-category").value.trim() || "Custom",
manufacturer: document.getElementById("cm-manufacturer").value.trim(),
part_number: document.getElementById("cm-partnumber").value.trim(),
description: document.getElementById("cm-description").value.trim(),
pin_count: pinCount,
pin_labels: [...document.querySelectorAll(".pin-label-input")].map(i => i.value.trim()),
};
const btn = document.getElementById("cm-save");
btn.disabled = true; btn.textContent = "Saving…";
try {
if (this._editingConnId != null) {
await api.connectors.update(this._editingConnId, data);
} else {
await api.connectors.create(data);
}
this.closeConnectorModal();
await this.loadCustomConnectors();
this._flashSaved();
} catch (e) {
alert("Failed to save connector: " + e.message);
} finally {
btn.disabled = false; btn.textContent = "Save";
}
}
async _deleteConnectorModal() {
if (this._editingConnId == null) return;
if (!confirm(`Delete "${document.getElementById("cm-name").value}"? This cannot be undone.`)) return;
try {
await api.connectors.delete(this._editingConnId);
this.closeConnectorModal();
await this.loadCustomConnectors();
this._flashSaved();
} catch (e) { alert("Failed to delete: " + e.message); }
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
_bindTabs() {
document.querySelectorAll(".sidebar-tab").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".sidebar-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
document.querySelectorAll(".tab-panel").forEach(p => {
p.style.display = p.dataset.panel === btn.dataset.tab ? "" : "none";
});
});
});
}
// ── Toolbar ───────────────────────────────────────────────────────────────────
_bindToolbar() {
const on = (id, fn) => document.getElementById(id)?.addEventListener("click", fn);
on("btn-select", () => this.setMode("select"));
on("btn-wire", () => this.setMode("wire"));
on("btn-delete", () => this.deleteSelected());
on("btn-duplicate", () => this.duplicateSelected());
on("btn-undo", () => this._undo());
on("btn-fit", () => this.canvas.fitView());
on("btn-new", () => this.newDiagram());
on("btn-bom", () => this._export("bom"));
on("btn-assembly", () => this._export("assembly"));
on("btn-json", () => this._export("json"));
on("btn-img", () => { const n = document.getElementById("diagram-name")?.value || "diagram"; this.canvas.exportImage(n); });
on("btn-formboard", () => this._export("formboard"));
on("btn-snap", () => {
const btn = document.getElementById("btn-snap");
const enabled = !this.canvas.snapEnabled;
this.canvas.toggleSnap(enabled);
btn?.classList.toggle("active", enabled);
});
on("btn-harness", () => {
const btn = document.getElementById("btn-harness");
const enabled = !this.canvas.harnessMode;
this.canvas.setHarnessMode(enabled);
btn?.classList.toggle("active", enabled);
});
on("btn-route-ortho", () => this._setRouteMode("ortho"));
on("btn-route-direct", () => this._setRouteMode("direct"));
on("btn-route-curved", () => this._setRouteMode("curved"));
document.getElementById("export-toggle")?.addEventListener("click", (e) => { e.stopPropagation(); document.getElementById("export-menu").classList.toggle("open"); });
document.addEventListener("click", (e) => {
document.getElementById("export-menu")?.classList.remove("open");
this._closeCtx();
if (!e.target.closest("#octopart-results") && !e.target.closest("#btn-octopart-search")) {
const res = document.getElementById("octopart-results");
if (res) res.style.display = "none";
}
});
document.addEventListener("contextmenu", (e) => {
// Close ctx menu if right-clicking outside canvas (browser default handles the rest)
if (!e.target.closest("#canvas-container")) this._closeCtx();
});
document.getElementById("diagram-name")?.addEventListener("change", async (e) => {
if (this.diagramId) { await api.diagrams.update(this.diagramId, { name: e.target.value }).catch(console.error); this._flashSaved(); }
});
}
// ── Context menu ──────────────────────────────────────────────────────────────
_ctxMenu() { return document.getElementById("ctx-menu"); }
_openCtx(x, y, items) {
const menu = this._ctxMenu();
menu.innerHTML = items.map(item => {
if (item === "---") return ``;
return `
${item.icon ? `${item.icon}` : ""}${item.label}
`;
}).join("");
menu.classList.add("open");
// Position, keeping within viewport
const vw = window.innerWidth, vh = window.innerHeight;
const mw = 180, mh = menu.childElementCount * 32;
menu.style.left = (x + mw > vw ? x - mw : x) + "px";
menu.style.top = (y + mh > vh ? y - mh : y) + "px";
}
_closeCtx() { this._ctxMenu().classList.remove("open"); }
_showCtxDevice(id, x, y) {
const d = this.canvas.deviceData.get(id);
const count = this.canvas.selectedDeviceIds.size;
const isMulti = count > 1;
const isGroup = d?.device_type === "group";
const isLocked = !!d?.properties?.locked;
const items = isMulti ? [
{ action: "dup", icon: "⊕", label: `Duplicate ${count} items` },
{ action: "zoom", icon: "⊞", label: "Zoom to selection" },
"---",
{ action: "align-left", icon: "⇤", label: "Align Left" },
{ action: "align-right", icon: "⇥", label: "Align Right" },
{ action: "align-top", icon: "⇡", label: "Align Top" },
{ action: "align-bottom", icon: "⇣", label: "Align Bottom" },
...(count >= 3 ? [
"---",
{ action: "dist-h", icon: "↔", label: "Distribute Horizontally" },
{ action: "dist-v", icon: "↕", label: "Distribute Vertically" },
] : []),
"---",
{ action: "del", icon: "✕", label: `Delete ${count} items`, danger: true },
] : [
{ action: "dup", icon: "⊕", label: "Duplicate" },
...(!isGroup ? [{ action: "addpin", icon: "+", label: "Add Pin" }] : []),
...(!isGroup ? [{ action: "savelb", icon: "⬆", label: "Save to Library" }] : []),
"---",
{ action: "front", icon: "▲", label: "Bring to Front" },
{ action: "back", icon: "▼", label: "Send to Back" },
"---",
isLocked
? { action: "unlock", icon: "🔓", label: "Unlock Position" }
: { action: "lock", icon: "🔒", label: "Lock Position" },
"---",
{ action: "del", icon: "✕", label: "Delete", danger: true },
];
this._openCtx(x, y, items);
this._ctxMenu().onclick = async (e) => {
const action = e.target.closest(".ctx-item")?.dataset.action;
this._closeCtx();
if (!action) return;
if (action === "dup") this.duplicateSelected();
if (action === "del") this.deleteSelected();
if (action === "zoom") this.canvas.zoomToSelection();
if (action === "addpin" && d) this._addDevicePin(d);
if (action === "savelb" && d) this._saveDeviceAsConnector(d);
if (action === "front") { this.canvas.bringToFront(id); const dd = this.canvas.deviceData.get(id); if (dd) { dd.properties = { ...(dd.properties||{}), zOrder: Date.now() }; api.devices.update(id, { properties: dd.properties }).catch(console.error); } }
if (action === "back") { this.canvas.sendToBack(id); const dd = this.canvas.deviceData.get(id); if (dd) { dd.properties = { ...(dd.properties||{}), zOrder: -Date.now() }; api.devices.update(id, { properties: dd.properties }).catch(console.error); } }
if (action === "lock") { this.canvas.setDeviceLocked(id, true); const dd = this.canvas.deviceData.get(id); if (dd) await api.devices.update(id, { properties: dd.properties }).catch(console.error); }
if (action === "unlock") { this.canvas.setDeviceLocked(id, false); const dd = this.canvas.deviceData.get(id); if (dd) await api.devices.update(id, { properties: dd.properties }).catch(console.error); }
if (action === "align-left") this._alignDevices("left");
if (action === "align-right") this._alignDevices("right");
if (action === "align-top") this._alignDevices("top");
if (action === "align-bottom") this._alignDevices("bottom");
if (action === "dist-h") this._distributeDevices("h");
if (action === "dist-v") this._distributeDevices("v");
};
}
_showCtxWire(id, x, y) {
const wire = this.canvas.wireData.get(id);
const hasWaypoints = (wire?.waypoints?.length ?? 0) > 0;
const items = [
{ action: "copystyle", icon: "⎘", label: "Copy Style" },
...(this._copiedWireStyle ? [{ action: "pastestyle", icon: "⎗", label: "Paste Style" }] : []),
...(wire?.label ? [{ action: "selectnet", icon: "⋈", label: `Select net "${wire.label}"` }] : []),
"---",
{ action: "straighten", icon: "⤢", label: "Straighten wire" },
...(hasWaypoints ? [{ action: "clearwp", icon: "⌀", label: "Clear waypoints" }] : []),
"---",
{ action: "del", icon: "✕", label: "Delete wire", danger: true },
];
this._openCtx(x, y, items);
this._ctxMenu().onclick = async (e) => {
const action = e.target.closest(".ctx-item")?.dataset.action;
this._closeCtx();
if (!action) return;
if (action === "del") this.deleteSelected();
if ((action === "clearwp" || action === "straighten") && wire) {
wire.waypoints = [];
this.canvas.updateWire(wire);
api.wires.update(id, { waypoints: [] }).catch(console.error);
this._flashSaved();
}
if (action === "copystyle" && wire) {
this._copiedWireStyle = {
color_primary: wire.color_primary,
color_stripe: wire.color_stripe,
gauge: wire.gauge,
twisted_pair: wire.twisted_pair,
twist_pitch: wire.twist_pitch,
shielded: wire.shielded,
show_size_label: wire.show_size_label,
};
}
if (action === "pastestyle" && wire && this._copiedWireStyle) {
Object.assign(wire, this._copiedWireStyle);
this.canvas.updateWire(wire);
await api.wires.update(id, this._copiedWireStyle).catch(console.error);
if (this.canvas.selectedId === id) this._showWireProps(wire);
this._flashSaved();
}
if (action === "selectnet" && wire?.label) {
this.canvas.highlightNet(wire.label);
}
};
}
_setRouteMode(mode) {
this.canvas.setRouteMode(mode);
document.querySelectorAll(".route-btn").forEach(b => b.classList.remove("active"));
document.getElementById(`btn-route-${mode}`)?.classList.add("active");
}
_bindKeyboard() {
document.addEventListener("keydown", (e) => {
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) return;
if (e.key === "Delete" || e.key === "Backspace") this.deleteSelected();
if (e.key === "Escape") { this.setMode("select"); this._closeCtx(); }
if (e.key === "w") this.setMode("wire");
if (e.key === "s") this.setMode("select");
if (e.key === "f" && !e.shiftKey) this.canvas.fitView();
if (e.key === "F" && e.shiftKey) this.canvas.zoomToSelection();
if (e.key === "g") { const btn = document.getElementById("btn-snap"); const en = !this.canvas.snapEnabled; this.canvas.toggleSnap(en); btn?.classList.toggle("active", en); }
if (e.key === "h") { const btn = document.getElementById("btn-harness"); const en = !this.canvas.harnessMode; this.canvas.setHarnessMode(en); btn?.classList.toggle("active", en); }
if ((e.ctrlKey || e.metaKey) && e.key === "c" && this.canvas.selectedType === "device") { e.preventDefault(); this.copySelected(); }
if ((e.ctrlKey || e.metaKey) && e.key === "v") { e.preventDefault(); this.pasteClipboard(); }
if ((e.ctrlKey || e.metaKey) && e.key === "d") { e.preventDefault(); this.duplicateSelected(); }
if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); this._undo(); }
});
}
// ── Properties panel ──────────────────────────────────────────────────────────
_bindProps() {
this._onPropChange("prop-reference", (v) => this._patchDevice({ reference: v }, true));
this._onPropChange("prop-label", (v) => this._patchDevice({ label: v }, true));
this._onPropChange("prop-partnumber", (v) => this._patchDeviceProp("partNumber", v));
this._onPropChange("prop-manufacturer", (v) => this._patchDeviceProp("manufacturer", v));
this._onPropChange("prop-fontsize", (v) => this._patchDeviceProp("fontSize", Math.max(6, Math.min(72, parseInt(v) || 12)), true));
this._onPropChange("wire-label", (v) => this._patchWire({ label: v }));
this._onPropChange("wire-gauge", (v) => this._patchWire({ gauge: v }));
this._onPropChange("wire-length", (v) => this._patchWire({ length: v ? parseFloat(v) : null }, true));
this._onPropChange("wire-unit", (v) => this._patchWire({ length_unit: v }, true));
this._onPropChange("wire-notes", (v) => this._patchWire({ notes: v }));
document.getElementById("wire-color")?.addEventListener("input", (e) => this._patchWire({ color_primary: e.target.value }, true));
document.getElementById("wire-stripe-enabled")?.addEventListener("change", (e) => {
const picker = document.getElementById("wire-stripe");
picker.disabled = !e.target.checked;
picker.style.opacity = e.target.checked ? "1" : "0.3";
this._patchWire({ color_stripe: e.target.checked ? picker.value : null }, true);
});
document.getElementById("wire-stripe")?.addEventListener("input", (e) => {
const isTwisted = document.getElementById("wire-twisted")?.checked;
if (isTwisted || document.getElementById("wire-stripe-enabled")?.checked) {
this._patchWire({ color_stripe: e.target.value }, true);
}
});
document.getElementById("btn-octopart-search")?.addEventListener("click", () => this._octopartSearch());
document.getElementById("group-fill-color")?.addEventListener("input", async (e) => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (!d || d.device_type !== "group") return;
d.properties = { ...d.properties, fillColor: e.target.value };
await api.devices.update(d.id, { properties: d.properties }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
document.getElementById("group-fill-opacity")?.addEventListener("input", async (e) => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (!d || d.device_type !== "group") return;
const opacity = parseFloat(e.target.value);
document.getElementById("group-fill-opacity-val").textContent = Math.round(opacity * 100) + "%";
d.properties = { ...d.properties, fillOpacity: opacity };
await api.devices.update(d.id, { properties: d.properties }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
document.getElementById("cable-jacket-color")?.addEventListener("input", async (e) => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (!d || d.device_type !== "cable") return;
d.properties = { ...d.properties, jacketColor: e.target.value };
await api.devices.update(d.id, { properties: d.properties }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
document.getElementById("cable-sleeve-length")?.addEventListener("input", async (e) => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (!d || d.device_type !== "cable") return;
const val = parseInt(e.target.value);
document.getElementById("cable-sleeve-length-val").textContent = val + " px";
d.properties = { ...d.properties, sleeveLength: val };
await api.devices.update(d.id, { properties: d.properties }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
document.getElementById("prop-add-conductor")?.addEventListener("click", () => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (d?.device_type === "cable") this._addConductor(d);
});
document.getElementById("wire-twisted")?.addEventListener("change", (e) => {
const pitchEl = document.getElementById("wire-twist-pitch");
pitchEl.disabled = !e.target.checked;
pitchEl.style.opacity = e.target.checked ? "1" : "0.3";
const updates = { twisted_pair: e.target.checked };
if (e.target.checked) {
const stripeEl = document.getElementById("wire-stripe");
if (!document.getElementById("wire-stripe-enabled").checked) {
stripeEl.value = "#cccccc";
updates.color_stripe = "#cccccc";
}
}
this._patchWire(updates, true);
this._updateWireTwistedUI(e.target.checked);
});
document.getElementById("wire-twist-pitch")?.addEventListener("change", (e) => {
this._patchWire({ twist_pitch: Math.max(8, Math.min(64, parseFloat(e.target.value) || 16)) }, true);
});
document.getElementById("wire-shielded")?.addEventListener("change", (e) => {
this._patchWire({ shielded: e.target.checked }, true);
});
document.getElementById("wire-show-size-label")?.addEventListener("change", (e) => {
this._patchWire({ show_size_label: e.target.checked }, true);
});
document.getElementById("wire-bundle-select")?.addEventListener("change", (e) => {
if (this._updatingBundleDropdown) return;
const bundleId = e.target.value ? parseInt(e.target.value) : null;
this._patchWire({ bundle_id: bundleId }, true);
this._refreshBundleEditPanel(bundleId);
});
document.getElementById("wire-bundle-new")?.addEventListener("click", async () => {
if (!this.diagramId) return;
try {
const b = await api.bundles.create({ diagram_id: this.diagramId, label: "New Bundle", jacket_color: "#2a2a2a" });
this._bundles.push(b);
this.canvas.loadBundles(this._bundles);
this._refreshBundleDropdown(b.id);
this._patchWire({ bundle_id: b.id }, true);
this._refreshBundleEditPanel(b.id);
} catch (e) { console.error("Create bundle failed:", e); }
});
document.getElementById("wire-bundle-label")?.addEventListener("change", async (e) => {
const bundleId = parseInt(document.getElementById("wire-bundle-select")?.value);
if (!bundleId) return;
try {
const updated = await api.bundles.update(bundleId, { label: e.target.value });
const idx = this._bundles.findIndex(b => b.id === bundleId);
if (idx >= 0) this._bundles[idx] = updated;
this.canvas.loadBundles(this._bundles);
this._refreshBundleDropdown(bundleId);
} catch (ex) { console.error(ex); }
});
document.getElementById("wire-bundle-color")?.addEventListener("input", async (e) => {
const bundleId = parseInt(document.getElementById("wire-bundle-select")?.value);
if (!bundleId) return;
try {
const updated = await api.bundles.update(bundleId, { jacket_color: e.target.value });
const idx = this._bundles.findIndex(b => b.id === bundleId);
if (idx >= 0) this._bundles[idx] = updated;
this.canvas.loadBundles(this._bundles);
} catch (ex) { console.error(ex); }
});
document.getElementById("wire-bundle-delete")?.addEventListener("click", async () => {
const bundleId = parseInt(document.getElementById("wire-bundle-select")?.value);
if (!bundleId || !confirm("Delete this bundle? Wires will be unassigned.")) return;
try {
await api.bundles.delete(bundleId);
this._bundles = this._bundles.filter(b => b.id !== bundleId);
this.canvas.wireData.forEach((w, wId) => {
if (w.bundle_id === bundleId) { w.bundle_id = null; api.wires.update(wId, { bundle_id: null }).catch(console.error); }
});
this.canvas.loadBundles(this._bundles); // also calls _renderBundles
const selWire = this.canvas.wireData.get(this.canvas.selectedId);
if (selWire?.bundle_id === null) this._refreshBundleDropdown(null);
else this._refreshBundleDropdown(selWire?.bundle_id || null);
this._refreshBundleEditPanel(selWire?.bundle_id || null);
} catch (ex) { console.error(ex); }
});
document.getElementById("prop-add-pin")?.addEventListener("click", () => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (d) this._addDevicePin(d);
});
document.getElementById("prop-save-connector")?.addEventListener("click", () => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (d) this._saveDeviceAsConnector(d);
});
const presetSelect = document.getElementById("wire-color-preset");
if (presetSelect) {
WIRE_COLORS.forEach(c => {
const opt = document.createElement("option");
opt.value = c.hex; opt.textContent = c.name;
presetSelect.appendChild(opt);
});
presetSelect.addEventListener("change", (e) => {
if (!e.target.value) return;
document.getElementById("wire-color").value = e.target.value;
this._patchWire({ color_primary: e.target.value }, true);
presetSelect.value = "";
});
}
}
_onPropChange(id, fn) { document.getElementById(id)?.addEventListener("change", (e) => fn(e.target.value)); }
async _patchDevice(data, redraw = false) {
if (this.canvas.selectedType !== "device") return;
const id = this.canvas.selectedId;
const d = this.canvas.deviceData.get(id);
const oldData = d ? Object.fromEntries(Object.keys(data).map(k => [k, d[k]])) : {};
if (d) Object.assign(d, data);
await api.devices.update(id, data).catch(console.error);
this._flashSaved();
if (redraw && d) this.canvas.updateDevice(d);
this._pushUndo(async () => {
const cur = this.canvas.deviceData.get(id);
if (cur) Object.assign(cur, oldData);
await api.devices.update(id, oldData).catch(console.error);
if (redraw && cur) this.canvas.updateDevice(cur);
this._flashSaved();
});
}
async _patchDeviceProp(key, value, redraw = false) {
if (this.canvas.selectedType !== "device") return;
const id = this.canvas.selectedId;
const d = this.canvas.deviceData.get(id);
if (!d) return;
const oldProps = { ...(d.properties || {}) };
d.properties = { ...oldProps, [key]: value };
await api.devices.update(id, { properties: d.properties }).catch(console.error);
this._flashSaved();
if (redraw) { this.canvas.updateDevice(d); this.canvas.selectDevice(id); }
this._pushUndo(async () => {
const cur = this.canvas.deviceData.get(id);
if (cur) { cur.properties = oldProps; }
await api.devices.update(id, { properties: oldProps }).catch(console.error);
if (redraw) { if (cur) this.canvas.updateDevice(cur); this.canvas.selectDevice(id); }
this._flashSaved();
});
}
async _patchWire(data, redraw = false) {
if (this.canvas.selectedType !== "wire") return;
const id = this.canvas.selectedId;
const w = this.canvas.wireData.get(id);
const oldData = w ? Object.fromEntries(Object.keys(data).map(k => [k, w[k]])) : {};
if (w) {
Object.assign(w, data);
if (redraw) this.canvas.updateWire(w);
}
try {
await api.wires.update(id, data);
this._flashSaved();
this._pushUndo(async () => {
const cur = this.canvas.wireData.get(id);
if (cur) { Object.assign(cur, oldData); this.canvas.updateWire(cur); }
await api.wires.update(id, oldData).catch(console.error);
this._flashSaved();
});
} catch (e) {
console.error("Wire save failed:", e);
this._flashError();
}
}
// ── Diagram management ────────────────────────────────────────────────────────
async loadDiagramList() {
const list = document.getElementById("diagram-list");
try {
const diagrams = await api.diagrams.list();
if (!diagrams.length) {
list.innerHTML = 'No diagrams yet
';
return;
}
list.innerHTML = "";
diagrams.forEach(d => {
const el = document.createElement("div");
el.className = "diagram-item" + (d.id === this.diagramId ? " active" : "");
el.dataset.id = d.id;
el.innerHTML = `${d.name}
${new Date(d.updated_at + "Z").toLocaleDateString()}
`;
el.querySelector(".di-name").addEventListener("click", () => this.openDiagram(d.id));
el.querySelector(".di-date").addEventListener("click", () => this.openDiagram(d.id));
el.querySelector(".di-del-btn").addEventListener("click", (e) => {
e.stopPropagation();
this.deleteDiagram(d.id, d.name);
});
list.appendChild(el);
});
} catch { list.innerHTML = 'Failed to load
'; }
}
async deleteDiagram(id, name) {
if (!confirm(`Delete "${name}" and all its contents? This cannot be undone.`)) return;
try {
await api.diagrams.delete(id);
if (this.diagramId === id) {
this.diagramId = null;
localStorage.removeItem("lastDiagramId");
this.canvas.loadDiagram({ devices: [], wires: [] });
document.getElementById("diagram-name").value = "";
document.getElementById("canvas-placeholder").style.display = "";
this._clearProps();
}
await this.loadDiagramList();
} catch (e) { alert("Failed to delete: " + e.message); }
}
async newDiagram() {
const name = prompt("Diagram name:", "New Diagram");
if (!name) return;
try {
const d = await api.diagrams.create({ name });
await this.loadDiagramList();
await this.openDiagram(d.id);
} catch (e) { alert("Failed to create: " + e.message); }
}
async openDiagram(id) {
try {
const diagram = await api.diagrams.get(id);
this.diagramId = id;
localStorage.setItem("lastDiagramId", id);
document.getElementById("diagram-name").value = diagram.name;
document.getElementById("canvas-placeholder").style.display = "none";
this._bundles = diagram.wire_bundles || [];
this.canvas.loadDiagram(diagram);
this.canvas.loadBundles(this._bundles);
if (diagram.devices?.length) setTimeout(() => this.canvas.fitView(), 50);
this.setMode("select");
this._clearProps();
await this.loadDiagramList();
} catch (e) {
localStorage.removeItem("lastDiagramId");
console.error("Failed to open diagram:", e);
}
}
// ── Actions ───────────────────────────────────────────────────────────────────
setMode(mode) {
this.canvas.setMode(mode);
document.querySelectorAll(".mode-btn").forEach(b => b.classList.remove("active"));
document.getElementById(`btn-${mode}`)?.classList.add("active");
const ind = document.getElementById("mode-indicator");
if (ind) ind.textContent = mode === "wire"
? "Wire Mode — drag from a pin to another pin to connect (Esc to cancel)"
: "";
}
async addDevice(typeKey, wx, wy) {
if (!this.diagramId) return this._needDiagram();
const def = buildDefaultDevice(typeKey, this.diagramId);
if (!def) return;
def.x = wx - def.width / 2;
def.y = wy - def.height / 2;
try {
const device = await api.devices.create(def);
this.canvas.addDevice(device);
this._flashSaved();
this._pushUndo(async () => {
this.canvas.removeDevice(device.id);
await api.devices.delete(device.id).catch(console.error);
this._flashSaved();
});
} catch (e) { console.error("Create device failed:", e); }
}
async addConnector(connId, wx, wy) {
if (!this.diagramId) return this._needDiagram();
let def;
if (connId.startsWith("custom_")) {
const id = parseInt(connId.replace("custom_", ""));
const conn = this._customConnectors.find(c => c.id === id);
if (!conn) return;
def = this._customConnectorToDevice(conn, this.diagramId);
} else {
def = connectorToDevice(connId, this.diagramId);
}
if (!def) return;
def.x = wx - def.width / 2;
def.y = wy - def.height / 2;
try {
const device = await api.devices.create(def);
this.canvas.addDevice(device);
this._flashSaved();
this._pushUndo(async () => {
this.canvas.removeDevice(device.id);
await api.devices.delete(device.id).catch(console.error);
this._flashSaved();
});
} catch (e) { console.error("Create connector failed:", e); }
}
_customConnectorToDevice(conn, diagramId) {
const pinCount = conn.pin_count;
const w = 120;
const h = Math.max(60, pinCount * 18 + 20);
const pins = Array.from({ length: pinCount }, (_, i) => ({
id: `pin_${i + 1}`,
name: (conn.pin_labels && conn.pin_labels[i]) || String(i + 1),
side: "right",
x_offset: w,
y_offset: ((i + 1) / (pinCount + 1)) * h,
}));
return {
diagram_id: diagramId,
device_type: "connector",
label: conn.name,
reference: "",
x: 200, y: 200,
width: w, height: h,
properties: { pinCount, orientation: "right", partNumber: conn.part_number || "", manufacturer: conn.manufacturer || "", customConnectorId: conn.id },
pins,
};
}
_conductorColorForPin(deviceId, pinId) {
const d = this.canvas.deviceData.get(deviceId);
if (d?.device_type !== "cable") return null;
const m = pinId.match(/^[LR](\d+)$/);
if (!m) return null;
return d.properties?.conductors?.[parseInt(m[1]) - 1]?.color || null;
}
async createWire(fromDev, fromPin, toDev, toPin) {
if (!this.diagramId) return;
try {
const autoColor = this._conductorColorForPin(fromDev, fromPin)
|| this._conductorColorForPin(toDev, toPin)
|| "#CC0000";
const wire = await api.wires.create({
diagram_id: this.diagramId,
from_device_id: fromDev, from_pin: fromPin,
to_device_id: toDev, to_pin: toPin,
color_primary: autoColor, gauge: "18 AWG",
});
this.canvas.addWire(wire);
this.setMode("select");
this.canvas.selectWire(wire.id);
this._showWireProps(wire);
this._flashSaved();
this._pushUndo(async () => {
this.canvas.clearSelection();
this._clearProps();
this.canvas.removeWire(wire.id);
await api.wires.delete(wire.id).catch(console.error);
this._flashSaved();
});
} catch (e) { console.error("Create wire failed:", e); }
}
async deleteSelected() {
const deviceIds = [...this.canvas.selectedDeviceIds];
if (deviceIds.length) {
// Snapshot data for undo before clearing
const deviceSnaps = deviceIds.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id)))).filter(Boolean);
const wireIds = new Set();
deviceIds.forEach(id => {
this.canvas.wireData.forEach((w, wId) => {
if (w.from_device_id === id || w.to_device_id === id) wireIds.add(wId);
});
});
const wireSnaps = [...wireIds].map(wId => JSON.parse(JSON.stringify(this.canvas.wireData.get(wId)))).filter(Boolean);
this.canvas.clearSelection();
this._clearProps();
try {
for (const wId of wireIds) { await api.wires.delete(wId); this.canvas.removeWire(wId); }
for (const id of deviceIds) { await api.devices.delete(id); this.canvas.removeDevice(id); }
this._flashSaved();
this._pushUndo(async () => {
for (const snap of deviceSnaps) {
const restored = await api.devices.create(snap).catch(console.error);
if (restored) this.canvas.addDevice(restored);
}
for (const snap of wireSnaps) {
const restored = await api.wires.create(snap).catch(console.error);
if (restored) this.canvas.addWire(restored);
}
this._flashSaved();
});
} catch (e) { console.error("Delete failed:", e); }
return;
}
if (this.canvas.selectedType === "wire" && this.canvas.selectedId) {
const id = this.canvas.selectedId;
const wireSnap = JSON.parse(JSON.stringify(this.canvas.wireData.get(id)));
this.canvas.clearSelection();
this._clearProps();
try {
await api.wires.delete(id); this.canvas.removeWire(id); this._flashSaved();
this._pushUndo(async () => {
const restored = await api.wires.create(wireSnap).catch(console.error);
if (restored) { this.canvas.addWire(restored); this._flashSaved(); }
});
}
catch (e) { console.error("Delete failed:", e); }
}
}
copySelected() {
const ids = [...this.canvas.selectedDeviceIds];
if (!ids.length) return;
this._clipboard = ids.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id)))).filter(Boolean);
this._pasteCount = 0;
}
async pasteClipboard() {
if (!this._clipboard?.length || !this.diagramId) return;
this._pasteCount++;
await this._cloneDevices(this._clipboard, this._pasteCount * 30);
}
async duplicateSelected() {
const ids = [...this.canvas.selectedDeviceIds];
if (!ids.length || !this.diagramId) return;
const sources = ids.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id)))).filter(Boolean);
this._clipboard = sources;
this._pasteCount = 1;
await this._cloneDevices(sources, 30);
}
async _cloneDevices(sources, offset) {
this.canvas.clearSelection();
const clonedIds = [];
for (const source of sources) {
const def = {
diagram_id: this.diagramId,
device_type: source.device_type,
label: source.label,
reference: "",
x: source.x + offset,
y: source.y + offset,
width: source.width,
height: source.height,
properties: JSON.parse(JSON.stringify(source.properties || {})),
pins: JSON.parse(JSON.stringify(source.pins || [])),
};
try {
const device = await api.devices.create(def);
this.canvas.addDevice(device);
this.canvas.addToSelection(device.id);
clonedIds.push(device.id);
} catch (e) { console.error("Clone device failed:", e); }
}
this._flashSaved();
this._pushUndo(async () => {
this.canvas.clearSelection();
this._clearProps();
for (const cid of clonedIds) {
this.canvas.removeDevice(cid);
await api.devices.delete(cid).catch(console.error);
}
this._flashSaved();
});
if (this.canvas.selectedDeviceIds.size === 1) {
const [id] = this.canvas.selectedDeviceIds;
this.canvas.selectedId = id;
this._showDeviceProps(this.canvas.deviceData.get(id));
} else if (this.canvas.selectedDeviceIds.size > 1) {
this._showMultiProps(this.canvas.selectedDeviceIds.size);
}
}
_export(type) {
if (!this.diagramId) return this._needDiagram();
document.getElementById("export-menu")?.classList.remove("open");
const url = type === "bom" ? api.export.bomUrl(this.diagramId)
: type === "assembly" ? api.export.assemblyUrl(this.diagramId)
: type === "formboard" ? api.export.formboardUrl(this.diagramId)
: api.export.jsonUrl(this.diagramId);
window.open(url, "_blank");
}
_needDiagram() { alert("Please open or create a diagram first."); }
// ── Octopart / Nexar part search ──────────────────────────────────────────────
async _checkOctopartStatus() {
try {
const { configured } = await api.octopart.status();
this._octopartReady = configured;
} catch { this._octopartReady = false; }
}
async _octopartSearch() {
const pn = document.getElementById("prop-partnumber")?.value?.trim();
const resultsEl = document.getElementById("octopart-results");
const statusEl = document.getElementById("octopart-status-msg");
const btn = document.getElementById("btn-octopart-search");
if (!resultsEl || !statusEl) return;
resultsEl.style.display = "none";
resultsEl.innerHTML = "";
statusEl.style.display = "none";
if (this._octopartReady === false) {
statusEl.textContent = "Nexar API not configured — set NEXAR_CLIENT_ID and NEXAR_CLIENT_SECRET on the server.";
statusEl.style.display = "";
return;
}
if (!pn) {
statusEl.textContent = "Enter a part number first.";
statusEl.style.display = "";
return;
}
btn?.classList.add("searching");
try {
const results = await api.octopart.search(pn, 8);
btn?.classList.remove("searching");
if (!results.length) {
resultsEl.innerHTML = `No parts found for "${pn}"
`;
resultsEl.style.display = "";
return;
}
resultsEl.innerHTML = results.map((r, i) => `
${r.mpn}
${r.manufacturer || "—"}
${r.description ? `
${r.description}
` : ""}
${r.datasheet_url ? `
📄 datasheet available
` : ""}
`).join("");
resultsEl.style.display = "";
resultsEl.querySelectorAll(".op-result").forEach((el, i) => {
el.addEventListener("click", () => {
this._applyOctopartResult(results[i]);
resultsEl.style.display = "none";
});
});
} catch (e) {
btn?.classList.remove("searching");
statusEl.textContent = `Search failed: ${e.message}`;
statusEl.style.display = "";
}
}
async _applyOctopartResult(result) {
if (this.canvas.selectedType !== "device") return;
const id = this.canvas.selectedId;
const d = this.canvas.deviceData.get(id);
if (!d) return;
document.getElementById("prop-partnumber").value = result.mpn;
document.getElementById("prop-manufacturer").value = result.manufacturer || "";
d.properties = {
...(d.properties || {}),
partNumber: result.mpn,
manufacturer: result.manufacturer || "",
description: result.description || "",
datasheetUrl: result.datasheet_url || "",
};
await api.devices.update(id, {
properties: d.properties,
reference: d.reference,
}).catch(console.error);
this._showDatasheetLink(result.datasheet_url);
this._flashSaved();
}
_showDatasheetLink(url) {
const row = document.getElementById("prop-datasheet-row");
const link = document.getElementById("prop-datasheet-link");
if (!row || !link) return;
if (url) {
link.href = url;
row.style.display = "";
} else {
row.style.display = "none";
}
}
// ── Cable management ──────────────────────────────────────────────────────────
_refreshGroupSection(device) {
const section = document.getElementById("group-section");
if (!section) return;
if (device.device_type !== "group") { section.style.display = "none"; return; }
section.style.display = "";
const color = device.properties?.fillColor || "#2828a0";
const opacity = device.properties?.fillOpacity ?? 0.15;
document.getElementById("group-fill-color").value = color;
document.getElementById("group-fill-opacity").value = opacity;
document.getElementById("group-fill-opacity-val").textContent = Math.round(opacity * 100) + "%";
}
_refreshCableSection(device) {
const section = document.getElementById("cable-section");
if (!section) return;
if (device.device_type !== "cable") { section.style.display = "none"; return; }
section.style.display = "";
document.getElementById("cable-jacket-color").value = device.properties?.jacketColor || "#2a2a2a";
const sl = device.properties?.sleeveLength ?? 60;
document.getElementById("cable-sleeve-length").value = sl;
document.getElementById("cable-sleeve-length-val").textContent = sl + " px";
const tbody = document.getElementById("conductor-table-body");
tbody.innerHTML = "";
(device.properties?.conductors || []).forEach((cond, i) => {
const tr = document.createElement("tr");
tr.innerHTML = `${i + 1} |
|
|
| `;
tr.querySelector(".conductor-name-input").addEventListener("change", async (e) => {
const d = this.canvas.deviceData.get(device.id);
if (!d?.properties?.conductors) return;
const idx = parseInt(e.target.dataset.idx);
d.properties.conductors[idx].name = e.target.value;
const lp = d.pins.find(p => p.id === `L${idx + 1}`);
const rp = d.pins.find(p => p.id === `R${idx + 1}`);
if (lp) lp.name = e.target.value;
if (rp) rp.name = e.target.value;
await api.devices.update(d.id, { properties: d.properties, pins: d.pins }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
tr.querySelector(".conductor-color-input").addEventListener("input", async (e) => {
const d = this.canvas.deviceData.get(device.id);
if (!d?.properties?.conductors) return;
d.properties.conductors[parseInt(e.target.dataset.idx)].color = e.target.value;
await api.devices.update(d.id, { properties: d.properties }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
tr.querySelector(".conductor-del-btn").addEventListener("click", () => {
const d = this.canvas.deviceData.get(device.id);
if (d) this._removeConductor(d, parseInt(tr.querySelector(".conductor-del-btn").dataset.idx));
});
tbody.appendChild(tr);
});
}
async _addConductor(device) {
const d = this.canvas.deviceData.get(device.id);
if (!d) return;
const conductors = [...(d.properties?.conductors || [])];
const color = CABLE_DEFAULT_COLORS[conductors.length % CABLE_DEFAULT_COLORS.length];
const i = conductors.length;
conductors.push({ name: String(i + 1), color });
const newH = conductors.length * 24 + 40;
d.properties = { ...d.properties, conductors, conductorCount: conductors.length };
d.height = newH;
d.pins.push(
{ id: `L${i + 1}`, name: String(i + 1), side: "left", x_offset: 0, y_offset: 26 + i * 24 + 12 },
{ id: `R${i + 1}`, name: String(i + 1), side: "right", x_offset: d.width, y_offset: 26 + i * 24 + 12 },
);
await api.devices.update(d.id, { properties: d.properties, height: newH, pins: d.pins }).catch(console.error);
this.canvas.updateDevice(d);
this.canvas.selectDevice(d.id);
this._flashSaved();
}
async _removeConductor(device, idx) {
const d = this.canvas.deviceData.get(device.id);
if (!d) return;
const conductors = [...(d.properties?.conductors || [])];
if (conductors.length <= 1) { alert("Cable must have at least one conductor."); return; }
conductors.splice(idx, 1);
d.pins = d.pins.filter(p => p.id !== `L${idx + 1}` && p.id !== `R${idx + 1}`);
// Renumber remaining pins
let li = 0, ri = 0;
d.pins = d.pins.map(p => {
if (p.side === "left") { const n = li++; return { ...p, id: `L${n+1}`, name: conductors[n]?.name || String(n+1), y_offset: 26 + n * 24 + 12 }; }
if (p.side === "right") { const n = ri++; return { ...p, id: `R${n+1}`, name: conductors[n]?.name || String(n+1), y_offset: 26 + n * 24 + 12 }; }
return p;
});
const newH = conductors.length * 24 + 40;
d.height = newH;
d.properties = { ...d.properties, conductors, conductorCount: conductors.length };
await api.devices.update(d.id, { properties: d.properties, height: newH, pins: d.pins }).catch(console.error);
this.canvas.updateDevice(d);
this.canvas.selectDevice(d.id);
this._flashSaved();
}
_updateWireTwistedUI(isTwisted) {
document.getElementById("wire-stripe-lbl").textContent = isTwisted ? "Wire 2 Color" : "Stripe Color";
document.getElementById("wire-stripe-toggle-lbl").style.display = isTwisted ? "none" : "";
const picker = document.getElementById("wire-stripe");
if (isTwisted) {
picker.disabled = false;
picker.style.opacity = "1";
} else {
const hasStripe = document.getElementById("wire-stripe-enabled").checked;
picker.disabled = !hasStripe;
picker.style.opacity = hasStripe ? "1" : "0.3";
}
}
// ── Properties panel ──────────────────────────────────────────────────────────
_showDeviceProps(device) {
this._setPropVisible("props-device");
document.getElementById("prop-type").textContent = DEVICE_TYPES[device.device_type]?.label || device.device_type;
document.getElementById("prop-reference").value = device.reference || "";
document.getElementById("prop-label").value = device.label || "";
document.getElementById("prop-partnumber").value = device.properties?.partNumber || "";
document.getElementById("prop-manufacturer").value = device.properties?.manufacturer || "";
document.getElementById("prop-fontsize").value = device.properties?.fontSize || 12;
const isGroup = device.device_type === "group";
if (!isGroup) this._showDatasheetLink(device.properties?.datasheetUrl || "");
else { const dr = document.getElementById("prop-datasheet-row"); if (dr) dr.style.display = "none"; }
document.getElementById("prop-save-connector").style.display = isGroup ? "none" : "";
document.getElementById("prop-add-pin").style.display = isGroup ? "none" : "";
document.getElementById("pin-table-body").closest(".prop-row").style.display = isGroup ? "none" : "";
// Reset any open search results
const res = document.getElementById("octopart-results");
const msg = document.getElementById("octopart-status-msg");
if (res) { res.style.display = "none"; res.innerHTML = ""; }
if (msg) msg.style.display = "none";
this._refreshCableSection(device);
this._refreshGroupSection(device);
const tbody = document.getElementById("pin-table-body");
tbody.innerHTML = "";
(device.pins || []).forEach(pin => {
const tr = document.createElement("tr");
const sides = ["left", "right", "top", "bottom"];
const sideOpts = sides.map(s => ``).join("");
tr.innerHTML = `${pin.id} |
|
|
| `;
tr.querySelector(".pin-side-select").addEventListener("change", async (e) => {
const d = this.canvas.deviceData.get(device.id);
if (!d) return;
const p = d.pins.find(p => p.id === pin.id);
if (!p) return;
p.side = e.target.value;
this._recalcPinOffsets(d.pins, d.width, d.height);
await api.devices.update(d.id, { pins: d.pins }).catch(console.error);
this.canvas.updateDevice(d);
this._showDeviceProps(d);
this._flashSaved();
});
tr.querySelector(".pin-del-btn").addEventListener("click", () => {
const d = this.canvas.deviceData.get(device.id);
if (d) this._removeDevicePin(d, pin.id);
});
tbody.appendChild(tr);
});
tbody.querySelectorAll(".pin-input").forEach(inp => {
inp.addEventListener("change", async (e) => {
const devId = parseInt(e.target.dataset.dev);
const pinId = e.target.dataset.pin;
const d = this.canvas.deviceData.get(devId);
if (!d) return;
const pin = d.pins.find(p => p.id === pinId);
if (pin) pin.name = e.target.value;
await api.devices.update(devId, { pins: d.pins }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
});
}
_showWireProps(wire) {
this._setPropVisible("props-wire");
const fd = this.canvas.deviceData.get(wire.from_device_id);
const td = this.canvas.deviceData.get(wire.to_device_id);
document.getElementById("wire-from").textContent = fd ? `${fd.reference || fd.label} : ${wire.from_pin}` : "—";
document.getElementById("wire-to").textContent = td ? `${td.reference || td.label} : ${wire.to_pin}` : "—";
document.getElementById("wire-label").value = wire.label || "";
document.getElementById("wire-color").value = wire.color_primary || "#cc0000";
const hasStripe = !!wire.color_stripe;
document.getElementById("wire-stripe-enabled").checked = hasStripe;
document.getElementById("wire-stripe").value = wire.color_stripe || "#cccccc";
document.getElementById("wire-stripe").disabled = !hasStripe;
document.getElementById("wire-stripe").style.opacity = hasStripe ? "1" : "0.3";
document.getElementById("wire-gauge").value = wire.gauge || "18 AWG";
document.getElementById("wire-length").value = wire.length != null ? wire.length : "";
document.getElementById("wire-unit").value = wire.length_unit || "in";
const isTwisted = !!wire.twisted_pair;
document.getElementById("wire-twisted").checked = isTwisted;
document.getElementById("wire-twist-pitch").value = wire.twist_pitch || 16;
document.getElementById("wire-twist-pitch").disabled = !isTwisted;
document.getElementById("wire-twist-pitch").style.opacity = isTwisted ? "1" : "0.3";
document.getElementById("wire-shielded").checked = !!wire.shielded;
document.getElementById("wire-show-size-label").checked = !!wire.show_size_label;
document.getElementById("wire-notes").value = wire.notes || "";
this._updateWireTwistedUI(isTwisted);
this._refreshBundleDropdown(wire.bundle_id || null);
this._refreshBundleEditPanel(wire.bundle_id || null);
}
_refreshBundleDropdown(selectedId) {
const sel = document.getElementById("wire-bundle-select");
if (!sel) return;
this._updatingBundleDropdown = true;
sel.innerHTML = '';
this._bundles.forEach(b => {
const opt = document.createElement("option");
opt.value = b.id;
opt.textContent = b.label || `Bundle #${b.id}`;
if (b.id === selectedId) opt.selected = true;
sel.appendChild(opt);
});
this._updatingBundleDropdown = false;
}
_refreshBundleEditPanel(bundleId) {
const panel = document.getElementById("wire-bundle-edit");
if (!panel) return;
if (!bundleId) { panel.style.display = "none"; return; }
const b = this._bundles.find(x => x.id === bundleId);
if (!b) { panel.style.display = "none"; return; }
panel.style.display = "";
document.getElementById("wire-bundle-label").value = b.label || "";
document.getElementById("wire-bundle-color").value = b.jacket_color || "#2a2a2a";
}
_clearProps() { this._setPropVisible("props-empty"); }
_showMultiProps(count) {
this._setPropVisible("props-multi");
const el = document.getElementById("props-multi-count");
if (el) el.textContent = `${count} items selected`;
}
_setPropVisible(activeId) {
["props-empty", "props-device", "props-wire", "props-multi"].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = id === activeId ? "" : "none";
});
}
// ── Pin management ────────────────────────────────────────────────────────────
_recalcPinOffsets(pins, width, height) {
["left", "right", "top", "bottom"].forEach(side => {
const sp = pins.filter(p => p.side === side);
sp.forEach((p, i) => {
const t = (i + 1) / (sp.length + 1);
if (side === "left") { p.x_offset = 0; p.y_offset = t * height; }
if (side === "right") { p.x_offset = width; p.y_offset = t * height; }
if (side === "top") { p.x_offset = t * width; p.y_offset = 0; }
if (side === "bottom") { p.x_offset = t * width; p.y_offset = height; }
});
});
}
async _addDevicePin(device) {
const pins = device.pins || [];
const lastSide = pins[pins.length - 1]?.side || "right";
const maxNum = pins.reduce((m, p) => Math.max(m, parseInt(p.id.replace(/\D/g, "")) || 0), 0);
const newPins = [...pins, { id: `pin_${maxNum + 1}`, name: String(maxNum + 1), side: lastSide, x_offset: 0, y_offset: 0 }];
const newHeight = Math.max(device.height || 60, newPins.length * 18 + 20);
this._recalcPinOffsets(newPins, device.width, newHeight);
device.pins = newPins;
device.height = newHeight;
await api.devices.update(device.id, { pins: newPins, height: newHeight }).catch(console.error);
this.canvas.updateDevice(device);
this._showDeviceProps(device);
this._flashSaved();
}
async _removeDevicePin(device, pinId) {
const connectedWireIds = [];
this.canvas.wireData.forEach((w, wId) => {
if ((w.from_device_id === device.id && w.from_pin === pinId) ||
(w.to_device_id === device.id && w.to_pin === pinId)) connectedWireIds.push(wId);
});
if (connectedWireIds.length && !confirm(`This pin has ${connectedWireIds.length} connected wire(s). Remove pin and its wires?`)) return;
for (const wId of connectedWireIds) {
await api.wires.delete(wId).catch(console.error);
this.canvas.removeWire(wId);
}
const newPins = (device.pins || []).filter(p => p.id !== pinId);
const newHeight = Math.max(60, newPins.length * 18 + 20);
this._recalcPinOffsets(newPins, device.width, newHeight);
device.pins = newPins;
device.height = newHeight;
await api.devices.update(device.id, { pins: newPins, height: newHeight }).catch(console.error);
this.canvas.updateDevice(device);
this._showDeviceProps(device);
this._flashSaved();
}
_saveDeviceAsConnector(device) {
const pins = device.pins || [];
const connData = {
name: device.label || "From Canvas",
manufacturer: device.properties?.manufacturer || "",
part_number: device.properties?.partNumber || "",
category: "Custom",
description: "",
pin_count: pins.length,
pin_labels: pins.map(p => p.name || ""),
};
const customConnId = device.properties?.customConnectorId;
if (customConnId != null) {
const existing = this._customConnectors.find(c => c.id === customConnId);
if (existing) { this.openConnectorModal({ ...existing, ...connData }); return; }
}
this.openConnectorModal(connData);
}
}
window.addEventListener("DOMContentLoaded", () => { window.app = new WiringApp(); });