82d8570767
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>
2420 lines
106 KiB
JavaScript
2420 lines
106 KiB
JavaScript
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, """)}">`;
|
||
container.appendChild(row);
|
||
}
|
||
}
|
||
|
||
async _saveConnectorModal() {
|
||
const name = document.getElementById("cm-name").value.trim();
|
||
const pinCount = parseInt(document.getElementById("cm-pincount").value);
|
||
if (!name) { document.getElementById("cm-name").focus(); return; }
|
||
if (!pinCount || pinCount < 1 || pinCount > 64) { alert("Pin count must be 1–64"); return; }
|
||
|
||
const data = {
|
||
name,
|
||
category: document.getElementById("cm-category").value.trim() || "Custom",
|
||
manufacturer: document.getElementById("cm-manufacturer").value.trim(),
|
||
part_number: document.getElementById("cm-partnumber").value.trim(),
|
||
description: document.getElementById("cm-description").value.trim(),
|
||
pin_count: pinCount,
|
||
pin_labels: [...document.querySelectorAll(".pin-label-input")].map(i => i.value.trim()),
|
||
};
|
||
|
||
const btn = document.getElementById("cm-save");
|
||
btn.disabled = true; btn.textContent = "Saving…";
|
||
try {
|
||
if (this._editingConnId != null) {
|
||
await api.connectors.update(this._editingConnId, data);
|
||
} else {
|
||
await api.connectors.create(data);
|
||
}
|
||
this.closeConnectorModal();
|
||
await this.loadCustomConnectors();
|
||
this._flashSaved();
|
||
} catch (e) {
|
||
alert("Failed to save connector: " + e.message);
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = "Save";
|
||
}
|
||
}
|
||
|
||
async _deleteConnectorModal() {
|
||
if (this._editingConnId == null) return;
|
||
if (!confirm(`Delete "${document.getElementById("cm-name").value}"? This cannot be undone.`)) return;
|
||
try {
|
||
await api.connectors.delete(this._editingConnId);
|
||
this.closeConnectorModal();
|
||
await this.loadCustomConnectors();
|
||
this._flashSaved();
|
||
} catch (e) { alert("Failed to delete: " + e.message); }
|
||
}
|
||
|
||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||
|
||
_bindTabs() {
|
||
document.querySelectorAll(".sidebar-tab").forEach(btn => {
|
||
btn.addEventListener("click", () => {
|
||
document.querySelectorAll(".sidebar-tab").forEach(b => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
document.querySelectorAll(".tab-panel").forEach(p => {
|
||
p.style.display = p.dataset.panel === btn.dataset.tab ? "" : "none";
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Toolbar ───────────────────────────────────────────────────────────────────
|
||
|
||
_bindToolbar() {
|
||
const on = (id, fn) => document.getElementById(id)?.addEventListener("click", fn);
|
||
on("btn-select", () => this.setMode("select"));
|
||
on("btn-wire", () => this.setMode("wire"));
|
||
on("btn-delete", () => this.deleteSelected());
|
||
on("btn-duplicate", () => this.duplicateSelected());
|
||
on("btn-undo", () => this._undo());
|
||
on("btn-fit", () => this.canvas.fitView());
|
||
on("btn-new", () => this.newDiagram());
|
||
on("btn-bom", () => this._export("bom"));
|
||
on("btn-assembly", () => this._export("assembly"));
|
||
on("btn-json", () => this._export("json"));
|
||
on("btn-img", () => { const n = document.getElementById("diagram-name")?.value || "diagram"; this.canvas.exportImage(n); });
|
||
on("btn-formboard", () => this._export("formboard"));
|
||
on("btn-git", () => this._openGitModal());
|
||
on("btn-drc", () => this._runDrc());
|
||
on("btn-import", () => document.getElementById("import-file-input")?.click());
|
||
document.getElementById("import-file-input")?.addEventListener("change", (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = async (ev) => {
|
||
try {
|
||
const data = JSON.parse(ev.target.result);
|
||
const diag = await api.diagrams.import(data);
|
||
await this.loadDiagramList();
|
||
await this.openDiagram(diag.id);
|
||
} catch (err) { alert("Import failed: " + err.message); }
|
||
e.target.value = "";
|
||
};
|
||
reader.readAsText(file);
|
||
});
|
||
|
||
on("btn-jumps", () => {
|
||
const btn = document.getElementById("btn-jumps");
|
||
const enabled = !this.canvas.jumpsEnabled;
|
||
this.canvas.setJumpsEnabled(enabled);
|
||
btn?.classList.toggle("active", enabled);
|
||
});
|
||
on("btn-snap", () => {
|
||
const btn = document.getElementById("btn-snap");
|
||
const enabled = !this.canvas.snapEnabled;
|
||
this.canvas.toggleSnap(enabled);
|
||
btn?.classList.toggle("active", enabled);
|
||
});
|
||
on("btn-harness", () => {
|
||
const btn = document.getElementById("btn-harness");
|
||
const enabled = !this.canvas.harnessMode;
|
||
this.canvas.setHarnessMode(enabled);
|
||
btn?.classList.toggle("active", enabled);
|
||
});
|
||
on("btn-route-ortho", () => this._setRouteMode("ortho"));
|
||
on("btn-route-direct", () => this._setRouteMode("direct"));
|
||
on("btn-route-curved", () => this._setRouteMode("curved"));
|
||
|
||
document.getElementById("export-toggle")?.addEventListener("click", (e) => { e.stopPropagation(); document.getElementById("export-menu").classList.toggle("open"); });
|
||
document.addEventListener("click", (e) => {
|
||
document.getElementById("export-menu")?.classList.remove("open");
|
||
this._closeCtx();
|
||
if (!e.target.closest("#octopart-results") && !e.target.closest("#btn-octopart-search")) {
|
||
const res = document.getElementById("octopart-results");
|
||
if (res) res.style.display = "none";
|
||
}
|
||
});
|
||
document.addEventListener("contextmenu", (e) => {
|
||
// Close ctx menu if right-clicking outside canvas (browser default handles the rest)
|
||
if (!e.target.closest("#canvas-container")) this._closeCtx();
|
||
});
|
||
|
||
document.getElementById("diagram-name")?.addEventListener("change", async (e) => {
|
||
if (this.diagramId) { await api.diagrams.update(this.diagramId, { name: e.target.value }).catch(console.error); this._flashSaved(); }
|
||
});
|
||
}
|
||
|
||
// ── Context menu ──────────────────────────────────────────────────────────────
|
||
|
||
_ctxMenu() { return document.getElementById("ctx-menu"); }
|
||
|
||
_openCtx(x, y, items) {
|
||
const menu = this._ctxMenu();
|
||
menu.innerHTML = items.map(item => {
|
||
if (item === "---") return `<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, """)}"></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, """)}"></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,"&").replace(/</g,"<").replace(/>/g,">"); }
|
||
|
||
// ── 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(); });
|