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() {