diff --git a/docs/inspector.js b/docs/inspector.js
index 48a9906..651ec0c 100644
--- a/docs/inspector.js
+++ b/docs/inspector.js
@@ -381,7 +381,9 @@ class Inspector {
if (hit) {
const res = await requestInput("Edit Node", "Node Name", hit.label, {
extraBtn: { label: 'Delete', value: '__DELETE__', class: 'danger' },
- helpHtml: PIN_HELP_HTML
+ helpHtml: PIN_HELP_HTML,
+ validate: validateNetName,
+ validateArgs: this.activeNet ? [this.activeNet.id] : null
});
if (res === '__DELETE__') {
const idx = this.activeNet.nodes.indexOf(hit.origNode);
@@ -914,12 +916,14 @@ class Inspector {
const nextIdx = this.activeNet ? this.activeNet.nodes.length + 1 : 1;
let defaultLabel = `P${nextIdx}`;
- // NEW: Async Smart Suggestion
+ // Async Smart Suggestion
const smartLabel = await this.getSuggestedLabel(imgId, x, y);
if (smartLabel) defaultLabel = smartLabel;
const label = await requestInput("Add Node", "Pad/Pin Name", defaultLabel, {
- helpHtml: (typeof PIN_HELP_HTML !== 'undefined') ? PIN_HELP_HTML : null
+ helpHtml: (typeof PIN_HELP_HTML !== 'undefined') ? PIN_HELP_HTML : null,
+ validate: validateNetName,
+ validateArgs: this.activeNet ? [this.activeNet.id] : null
});
if(label) {
diff --git a/docs/nets.js b/docs/nets.js
index 82e04db..b735a4c 100644
--- a/docs/nets.js
+++ b/docs/nets.js
@@ -1,15 +1,155 @@
-/* nets.js - Netlist Management (v2) */
+/* nets.js - Netlist Management (v3) */
class NetManager {
constructor(db) {
this.db = db;
+ this.showOnlyProblematic = false; // Toggle state
}
-async render() {
+ // Updates the global toolbar button state
+ updateTopBar(problemCount) {
+ const bar = document.querySelector('#view-nets .actions-bar');
+ if (!bar) return;
+ let fixBtn = document.getElementById('net-fix-btn');
+ if (!fixBtn) {
+ fixBtn = document.createElement('button');
+ fixBtn.id = 'net-fix-btn';
+ fixBtn.onclick = () => this.toggleFixMode();
+ bar.appendChild(fixBtn);
+ }
+
+ if (problemCount === 0) {
+ fixBtn.className = 'secondary';
+ fixBtn.innerHTML = '✅ All Valid';
+ fixBtn.disabled = true;
+ fixBtn.style.opacity = '0.7';
+ this.showOnlyProblematic = false; // Auto-reset if everything is fixed
+ } else {
+ fixBtn.disabled = false;
+ fixBtn.style.opacity = '1';
+ if (this.showOnlyProblematic) {
+ fixBtn.className = 'primary'; // Active/Pressed state
+ fixBtn.innerHTML = `Showing ${problemCount} Issues (Click to Reset)`;
+ } else {
+ fixBtn.className = 'danger'; // Needs attention state
+ fixBtn.innerHTML = `⚠️ ${problemCount} Issues Found`;
+ }
+ }
+ }
+
+ // Toggles the filter and checks for empty nets
+ async toggleFixMode() {
+ if (this.showOnlyProblematic) {
+ this.showOnlyProblematic = false;
+ this.render();
+ return;
+ }
+
+ // Turning ON: Check for empty nets first
+ const allNets = await this.db.getNets();
+ const nets = allNets.filter(n => n.projectId === currentBomId);
+ const emptyNets = nets.filter(n => !n.nodes || n.nodes.length === 0);
+
+ if (emptyNets.length > 0) {
+ if (await confirmAction(`Found ${emptyNets.length} empty net(s).\n\nDelete them to clean up?`, "Delete Empty Nets")) {
+ for (const n of emptyNets) {
+ await this.db.deleteNet(n.id);
+ }
+ }
+ }
+
+ this.showOnlyProblematic = true;
+ this.render();
+ }
+
+ // Conflict Resolution Logic
+ async resolveNet(netId) {
+ const allNets = await this.db.getNets();
+ const projectNets = allNets.filter(n => n.projectId === currentBomId);
+ const net = projectNets.find(n => n.id === netId);
+ if (!net) return;
+
+ // 1. Handle Empty Nets
+ if (!net.nodes || net.nodes.length === 0) {
+ if (await confirmAction(`This net is completely empty.\n\nDelete it?`, "Delete")) {
+ await this.db.deleteNet(net.id);
+ this.render();
+ }
+ return;
+ }
+
+ // Rebuild mapping locally to find exact duplicates
+ const nodeMap = {};
+ projectNets.forEach(n => {
+ if(!n.nodes) return;
+ n.nodes.forEach(node => {
+ if (!nodeMap[node.label]) nodeMap[node.label] = [];
+ nodeMap[node.label].push(n);
+ });
+ });
+
+ // 2. Check for formatting errors first
+ const formatErrIdx = net.nodes.findIndex(n => !/^[A-Za-z0-9_-]+\.[1-9][0-9]*$/.test(n.label));
+ if (formatErrIdx > -1) {
+ const node = net.nodes[formatErrIdx];
+ alert(`Node "${node.label}" has an invalid format.\n\nPlease rename it using the "Ref.Pin" format (e.g., R1.1).`);
+ return this.editNode(net.id, formatErrIdx);
+ }
+
+ // 3. Check for duplicates and build explanation matrix
+ const conflicts =[];
+ const otherNetsMap = new Map(); // Maps id -> net object
+
+ net.nodes.forEach(node => {
+ if (nodeMap[node.label] && nodeMap[node.label].length > 1) {
+ const others = nodeMap[node.label].filter(n => n.id !== net.id);
+ others.forEach(o => {
+ conflicts.push(`• ${node.label} is also in ${o.name}`);
+ otherNetsMap.set(o.id, o);
+ });
+ }
+ });
+
+ if (conflicts.length > 0) {
+ const otherNets = Array.from(otherNetsMap.values());
+ // Pick the first conflicting net as the target for merging
+ const targetNet = otherNets[0];
+
+ let msg = `Conflict: Nodes in this net exist elsewhere:\n${conflicts.join('\n')}\n\n`;
+ msg += `Would you like to merge all nodes from ${net.name} into ${targetNet.name} and delete ${net.name}?\n\n`;
+ msg += `(Click "Cancel" to close this dialog and rename/fix the nodes manually)`;
+
+ // confirmAction already sets focus to the Cancel button by default
+ const doMerge = await confirmAction(msg, `Merge into ${targetNet.name}`);
+
+ if (doMerge) {
+ // SAFETY CHECK: Warn if merging a larger net into a smaller one
+ if (net.nodes.length > targetNet.nodes.length) {
+ const warnMsg = `WARNING: You are merging a larger net (${net.nodes.length} nodes) into a smaller net (${targetNet.nodes.length} nodes).\n\nAre you sure you want to completely merge ${net.name} into ${targetNet.name}?`;
+ const sure = await confirmAction(warnMsg, "Yes, Merge Anyway");
+ if (!sure) return;
+ }
+
+ // Perform Merge (add all nodes, skipping exact label duplicates)
+ const targetLabels = new Set(targetNet.nodes.map(n => n.label));
+ for (const n of net.nodes) {
+ if (!targetLabels.has(n.label)) {
+ targetNet.nodes.push(n);
+ targetLabels.add(n.label);
+ }
+ }
+ await this.db.addNet(targetNet);
+ await this.db.deleteNet(net.id);
+ this.render();
+ }
+ }
+ }
+
+ async render() {
const tbody = document.getElementById('nets-body');
if(!tbody) return;
- // Safety check: If no board is loaded, clear the table
+ // Safety check
if (typeof currentBomId === 'undefined' || !currentBomId) {
tbody.innerHTML = '';
return;
@@ -18,10 +158,43 @@ async render() {
tbody.innerHTML = '
| Loading... |
';
const allNets = await this.db.getNets();
-
- // --- FIX: Filter by Current Board ID ---
const nets = allNets.filter(n => n.projectId === currentBomId);
+ // --- NEW: Pre-calculate errors ---
+ const nodeMap = {}; // label -> array of netIds
+ let totalProblems = 0;
+
+ nets.forEach(net => {
+ if (!net.nodes || net.nodes.length === 0) {
+ net._hasError = true;
+ net._isEmpty = true;
+ } else {
+ net.nodes.forEach(node => {
+ if (!nodeMap[node.label]) nodeMap[node.label] =[];
+ nodeMap[node.label].push(net.id);
+ });
+ }
+ });
+
+ nets.forEach(net => {
+ let netHasError = net._isEmpty || false;
+ if (net.nodes) {
+ net.nodes.forEach(node => {
+ node._isFormatInvalid = !/^[A-Za-z0-9_-]+\.[1-9][0-9]*$/.test(node.label);
+ node._isDuplicate = nodeMap[node.label] && nodeMap[node.label].length > 1;
+ if (node._isFormatInvalid || node._isDuplicate) {
+ node._hasError = true;
+ netHasError = true;
+ }
+ });
+ }
+ net._hasError = netHasError;
+ if (netHasError) totalProblems++;
+ });
+
+ // Update toolbar toggle
+ this.updateTopBar(totalProblems);
+
tbody.innerHTML = '';
if(nets.length === 0) {
@@ -29,28 +202,45 @@ async render() {
return;
}
- nets.forEach(net => {
- const tr = document.createElement('tr');
+ let renderedCount = 0;
+ nets.forEach(net => {
+ // Filter check
+ if (this.showOnlyProblematic && !net._hasError) return;
+
+ renderedCount++;
+ const tr = document.createElement('tr');
tr.style.height = 'auto';
tr.style.minHeight = '2.2rem';
- // Target Icon for Editing
const targetIcon = `🎯`;
+ // NEW: Net Level Fix Button
+ const fixBtn = net._hasError ? `` : '';
+
let nodesHtml = '';
net.nodes.forEach((n, idx) => {
- nodesHtml += `${n.label}`;
+ let style = "class='net-chip'";
+ let warn = "";
+ // NEW: Node Level Warning Styles
+ if (n._hasError) {
+ style = "class='net-chip' style='border-color:#ef4444; background:#fef2f2; color:#b91c1c;'";
+ warn = n._isDuplicate ? " ⚠️(Dup)" : " ❌(Fmt)";
+ }
+ nodesHtml += `${n.label}${warn}`;
});
tr.innerHTML = `
-
+ |
+
${targetIcon}
+ ${fixBtn}
+
|
- ${nodesHtml}
+ ${nodesHtml}
|
@@ -59,6 +249,11 @@ async render() {
`;
tbody.appendChild(tr);
});
+
+ // Empty state if filter hides everything
+ if (renderedCount === 0 && this.showOnlyProblematic) {
+ tbody.innerHTML = ' | | No issues found! 🎉 |
';
+ }
}
// Edit Net in Inspector
@@ -80,7 +275,9 @@ async render() {
const res = await requestInput("Edit Node", "Node Name", node.label, {
extraBtn: { label: 'Delete', value: '__DELETE__', class: 'danger' },
- helpHtml: PIN_HELP_HTML
+ helpHtml: PIN_HELP_HTML,
+ validate: validateNetName,
+ validateArgs: [netId]
});
if (res === '__DELETE__') {
diff --git a/docs/studio.js b/docs/studio.js
index 25d400a..d55c0eb 100644
--- a/docs/studio.js
+++ b/docs/studio.js
@@ -1397,23 +1397,54 @@ function confirmAction(message, btnText = "Confirm") {
const msgEl = document.getElementById('confirm-msg');
const okBtn = document.getElementById('confirm-btn-ok');
const cancelBtn = document.getElementById('confirm-btn-cancel');
+ const closeBtn = modal.querySelector('.close-btn');
+ const modalContext = 'confirmation-modal';
msgEl.innerText = message;
okBtn.innerText = btnText;
- // Cleanup old handlers by reassigning
- okBtn.onclick = () => {
+ let resultToResolve = false;
+
+ // 1. Cleanup & Resolve
+ const close = () => {
+ window.removeEventListener('popstate', onPopState);
modal.style.display = 'none';
- resolve(true);
+ resolve(resultToResolve);
};
- cancelBtn.onclick = () => {
- modal.style.display = 'none';
- resolve(false);
+ // 2. Handle History Changes (Back Button / Escape)
+ const onPopState = () => {
+ close();
};
+ // 3. Handle UI Actions (OK / Cancel)
+ const commit = (res) => {
+ resultToResolve = res;
+ if (history.state && history.state.context === modalContext) {
+ history.back(); // This triggers onPopState -> close()
+ } else {
+ close();
+ }
+ };
+
+ // 4. Setup DOM (Clone to cleanly remove old listeners)
+ const newOk = okBtn.cloneNode(true);
+ const newCancel = cancelBtn.cloneNode(true);
+ okBtn.replaceWith(newOk);
+ cancelBtn.replaceWith(newCancel);
+
+ newOk.onclick = (e) => { e.stopPropagation(); commit(true); };
+ newCancel.onclick = (e) => { e.stopPropagation(); commit(false); };
+ closeBtn.onclick = (e) => { e.stopPropagation(); e.preventDefault(); commit(false); };
+
+ // 5. Open & Push State
+ if (!history.state || history.state.context !== modalContext) {
+ history.pushState({ context: modalContext }, "", "");
+ }
+
+ window.addEventListener('popstate', onPopState);
modal.style.display = 'flex';
- cancelBtn.focus(); // Default focus on Cancel
+ newCancel.focus(); // Default focus on Cancel
});
}
@@ -2454,6 +2485,21 @@ async function autoStitchNewImage(newId) {
}
}
+// --- NEW HELPER: Check Node Collision ---
+async function checkNodeCollision(nodeLabel, excludeNetId = null) {
+ if (!nodeLabel || typeof currentBomId === 'undefined') return[];
+ const allNets = await db.getNets();
+ const projectNets = allNets.filter(n => n.projectId === currentBomId);
+ const collisions =[];
+ for (const net of projectNets) {
+ if (net.id === excludeNetId) continue;
+ if (net.nodes && net.nodes.some(n => n.label === nodeLabel)) {
+ collisions.push(net.name);
+ }
+ }
+ return collisions;
+}
+
const ImageGraph = {
// Helper: Invert 3x3 Matrix
invertH(m) {
@@ -2721,6 +2767,7 @@ function requestInput(title, label, val, opts = {}) {
}
let resultToResolve = null;
+ let isInputValid = true; // Updated by opts.validate; always true when no validator is set
// 1. Cleanup & Resolve
const close = () => {
@@ -2738,6 +2785,18 @@ function requestInput(title, label, val, opts = {}) {
// 3. Handle UI Actions (OK / Cancel)
const commit = (v) => {
+ // Block submission when a validator is attached and the current value is invalid.
+ // Cancel (null) and extra-button sentinel values (e.g. '__DELETE__') always pass through.
+ if (v !== null && opts.validate && !isInputValid && v !== '__DELETE__') {
+ inp.style.borderColor = '#ef4444';
+ inp.style.backgroundColor = '#fef2f2';
+ setTimeout(() => {
+ inp.style.borderColor = '#cbd5e1';
+ inp.style.backgroundColor = '';
+ }, 400);
+ return; // Block submission
+ }
+
resultToResolve = v;
// SAFETY CHECK: Only go back if we are still in the modal state.
@@ -2782,6 +2841,33 @@ function requestInput(title, label, val, opts = {}) {
// Let NavManager handle Escape -> history.back()
};
+ // Generic live-validation hook.
+ // opts.validate: async (rawValue: string, ...args) => { sanitized?: string, isValid: boolean, feedbackHtml: string }
+ // The function owns all domain-specific logic (sanitization, format checks, collision lookups,
+ // etc.). requestInput only wires up the plumbing and gates OK on isValid.
+ if (opts.validate) {
+ const validateArgs = opts.validateArgs ? opts.validateArgs : [];
+ inp.addEventListener('input', async () => {
+ const result = await opts.validate(inp.value, ...validateArgs);
+
+ // Apply sanitized value if the validator changed it
+ if (result.sanitized !== undefined && inp.value !== result.sanitized) {
+ const pos = inp.selectionStart;
+ inp.value = result.sanitized;
+ inp.setSelectionRange(pos, pos);
+ }
+
+ isInputValid = result.isValid;
+
+ // Always show the help area while the user is typing so feedback is visible
+ helpToggle.style.display = 'block';
+ helpContent.style.display = 'block';
+ helpContent.innerHTML = result.feedbackHtml + (opts.helpHtml || '');
+ });
+ // Trigger once on open so the field is validated immediately
+ setTimeout(() => inp.dispatchEvent(new Event('input')), 50);
+ }
+
// 5. Open & Push State
// Use direct history.pushState to ensure it happens immediately and locally
// (Bypassing any potential NavManager checks/delays)
@@ -2796,6 +2882,51 @@ function requestInput(title, label, val, opts = {}) {
});
}
+/**
+ * Validator for net node names (for requestInput).
+ */
+async function validateNetName(raw, netId) {
+ // 1. Sanitize: strip spaces, enforce single dot with digits-only suffix
+ let v = raw.replace(/\s+/g, '');
+ const parts = v.split('.');
+ if (parts.length > 2) {
+ v = parts[0] + '.' + parts.slice(1).join('').replace(/[^0-9]/g, '');
+ } else if (parts.length === 2) {
+ v = parts[0] + '.' + parts[1].replace(/[^0-9]/g, '');
+ }
+
+ if (!v) {
+ return { sanitized: v, isValid: false, feedbackHtml: '' };
+ }
+
+ // 2. Format check
+ const validFormat = /^[A-Za-z0-9_-]+\.[1-9][0-9]*$/.test(v);
+ if (!validFormat) {
+ return {
+ sanitized: v,
+ isValid: false,
+ feedbackHtml: '❌ Format must be Ref.Pin (e.g. R1.1)
',
+ };
+ }
+
+ // 3. Collision check
+ const collisions = await checkNodeCollision(v, netId);
+ if (collisions.length > 0) {
+ return {
+ sanitized: v,
+ isValid: true, // Warn but still allow — user may be intentionally reassigning
+ feedbackHtml: `⚠️ Node already used in net(s): ${collisions.join(', ')}
`,
+ };
+ }
+
+ return {
+ sanitized: v,
+ isValid: true,
+ feedbackHtml: '✅ Valid format
',
+ };
+}
+
+
/* --- URL IMPORT LOGIC --- */
async function importDeviceFromURL() {