Fixed manual image stitching and Inspector view for mobiles, other minor fixes/improvements.
This commit is contained in:
parent
55e55edee9
commit
ca9fe52db5
10 changed files with 803 additions and 618 deletions
|
|
@ -1,4 +1,4 @@
|
|||
/* canvas-ui.js - Shared Canvas Logic */
|
||||
/* canvas-ui.js - Shared Canvas Logic (Mobile/Touch Ready) */
|
||||
|
||||
class PanZoomCanvas {
|
||||
constructor(id, onDraw, onClick, onDragPt) {
|
||||
|
|
@ -6,18 +6,23 @@ class PanZoomCanvas {
|
|||
this.container = this.canvas ? this.canvas.parentElement : null;
|
||||
this.ctx = this.canvas ? this.canvas.getContext('2d') : null;
|
||||
this.bmp = null;
|
||||
|
||||
this.t = {x:0, y:0, k:1}; // Changed default k to 1, but fit() will override
|
||||
|
||||
this.drag = false;
|
||||
|
||||
this.t = {x:0, y:0, k:1};
|
||||
|
||||
this.lm = {x:0, y:0};
|
||||
this.activePtIdx = -1;
|
||||
this.isMirrored = false;
|
||||
|
||||
this.onDraw = onDraw;
|
||||
this.onClick = onClick;
|
||||
|
||||
this.onDraw = onDraw;
|
||||
this.onClick = onClick;
|
||||
this.onDragPt = onDragPt;
|
||||
this.onMouseMove = null;
|
||||
this.onMouseMove = null;
|
||||
this.onPointerDown = null; // New Hook
|
||||
|
||||
this.evCache = [];
|
||||
this.prevDiff = -1;
|
||||
this.isDragging = false;
|
||||
this.totalDragDist = 0;
|
||||
|
||||
if(this.container) {
|
||||
new ResizeObserver(() => {
|
||||
|
|
@ -32,71 +37,125 @@ class PanZoomCanvas {
|
|||
}
|
||||
|
||||
initEvents() {
|
||||
this.canvas.onwheel = e => {
|
||||
this.canvas.style.touchAction = 'none';
|
||||
|
||||
this.canvas.addEventListener('wheel', e => {
|
||||
e.preventDefault();
|
||||
const f = Math.exp(-e.deltaY * 0.001);
|
||||
const r = this.canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - r.left, my = e.clientY - r.top;
|
||||
const wx = (mx - this.t.x) / this.t.k, wy = (my - this.t.y) / this.t.k;
|
||||
this.t.k = Math.max(0.01, Math.min(20, this.t.k * f));
|
||||
this.t.x = mx - wx * this.t.k;
|
||||
this.t.y = my - wy * this.t.k;
|
||||
this.draw();
|
||||
};
|
||||
this.zoomAt(e.clientX, e.clientY, f);
|
||||
}, { passive: false });
|
||||
|
||||
this.canvas.onmousedown = e => {
|
||||
const coords = this.getImgCoords(e.clientX, e.clientY);
|
||||
if (this.onDragPt) {
|
||||
const idx = this.onDragPt(coords.x, coords.y, 'check');
|
||||
if (idx !== -1) {
|
||||
this.activePtIdx = idx;
|
||||
this.drag = true;
|
||||
this.lm = { x: e.clientX, y: e.clientY };
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.drag = true;
|
||||
this.lm = { x: e.clientX, y: e.clientY };
|
||||
this.activePtIdx = -1;
|
||||
};
|
||||
this.canvas.addEventListener('pointerdown', e => this.handlePointerDown(e));
|
||||
this.canvas.addEventListener('pointermove', e => this.handlePointerMove(e));
|
||||
this.canvas.addEventListener('pointerup', e => this.handlePointerUp(e));
|
||||
this.canvas.addEventListener('pointercancel', e => this.handlePointerUp(e));
|
||||
this.canvas.addEventListener('pointerout', e => this.handlePointerUp(e));
|
||||
this.canvas.addEventListener('pointerleave', e => this.handlePointerUp(e));
|
||||
|
||||
this.canvas.oncontextmenu = e => {
|
||||
e.preventDefault();
|
||||
this.canvas.addEventListener('contextmenu', e => {
|
||||
const coords = this.getImgCoords(e.clientX, e.clientY);
|
||||
if (this.onDragPt) this.onDragPt(coords.x, coords.y, 'delete');
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', e => {
|
||||
if (this.drag) {
|
||||
if (this.activePtIdx !== -1) {
|
||||
const r = this.canvas.getBoundingClientRect();
|
||||
const curr = this.getImgCoords(e.clientX, e.clientY);
|
||||
const prev = this.getImgCoords(this.lm.x, this.lm.y);
|
||||
const dx = curr.x - prev.x;
|
||||
const dy = curr.y - prev.y;
|
||||
this.onDragPt(dx, dy, 'move', this.activePtIdx);
|
||||
this.lm = { x: e.clientX, y: e.clientY };
|
||||
} else {
|
||||
this.t.x += e.clientX - this.lm.x;
|
||||
this.t.y += e.clientY - this.lm.y;
|
||||
this.lm = { x: e.clientX, y: e.clientY };
|
||||
this.draw();
|
||||
zoomAt(clientX, clientY, factor) {
|
||||
const r = this.canvas.getBoundingClientRect();
|
||||
const mx = clientX - r.left;
|
||||
const my = clientY - r.top;
|
||||
const wx = (mx - this.t.x) / this.t.k;
|
||||
const wy = (my - this.t.y) / this.t.k;
|
||||
this.t.k = Math.max(0.01, Math.min(20, this.t.k * factor));
|
||||
this.t.x = mx - wx * this.t.k;
|
||||
this.t.y = my - wy * this.t.k;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
handlePointerDown(e) {
|
||||
this.canvas.setPointerCapture(e.pointerId);
|
||||
this.evCache.push(e);
|
||||
this.totalDragDist = 0;
|
||||
|
||||
// Trigger Hook if defined (Before internal logic)
|
||||
if (this.onPointerDown) this.onPointerDown(e);
|
||||
|
||||
const coords = this.getImgCoords(e.clientX, e.clientY);
|
||||
|
||||
if (this.onDragPt) {
|
||||
const idx = this.onDragPt(coords.x, coords.y, 'check');
|
||||
if (idx !== -1) {
|
||||
this.activePtIdx = idx;
|
||||
this.isDragging = true;
|
||||
this.lm = { x: e.clientX, y: e.clientY };
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.isDragging = true;
|
||||
this.lm = { x: e.clientX, y: e.clientY };
|
||||
this.activePtIdx = -1;
|
||||
}
|
||||
|
||||
handlePointerMove(e) {
|
||||
const index = this.evCache.findIndex(cached => cached.pointerId === e.pointerId);
|
||||
if (index > -1) this.evCache[index] = e;
|
||||
|
||||
if (this.evCache.length === 2) {
|
||||
// Pinch Zoom
|
||||
const dx = this.evCache[0].clientX - this.evCache[1].clientX;
|
||||
const dy = this.evCache[0].clientY - this.evCache[1].clientY;
|
||||
const curDiff = Math.hypot(dx, dy);
|
||||
|
||||
if (this.prevDiff > 0) {
|
||||
const factor = 1 + ((curDiff - this.prevDiff) * 0.01);
|
||||
const cx = (this.evCache[0].clientX + this.evCache[1].clientX) / 2;
|
||||
const cy = (this.evCache[0].clientY + this.evCache[1].clientY) / 2;
|
||||
this.zoomAt(cx, cy, factor);
|
||||
}
|
||||
this.prevDiff = curDiff;
|
||||
this.totalDragDist += 20; // Ensure pinch doesn't trigger click
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isDragging && this.evCache.length === 1) {
|
||||
const dx = e.clientX - this.lm.x;
|
||||
const dy = e.clientY - this.lm.y;
|
||||
|
||||
this.totalDragDist += Math.hypot(dx, dy);
|
||||
|
||||
if (this.activePtIdx !== -1) {
|
||||
const curr = this.getImgCoords(e.clientX, e.clientY);
|
||||
const prev = this.getImgCoords(this.lm.x, this.lm.y);
|
||||
this.onDragPt(curr.x - prev.x, curr.y - prev.y, 'move', this.activePtIdx);
|
||||
} else {
|
||||
this.t.x += dx;
|
||||
this.t.y += dy;
|
||||
this.draw();
|
||||
}
|
||||
this.lm = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
if(this.onMouseMove) {
|
||||
const coords = this.getImgCoords(e.clientX, e.clientY);
|
||||
this.onMouseMove(coords.x, coords.y);
|
||||
}
|
||||
}
|
||||
|
||||
handlePointerUp(e) {
|
||||
const index = this.evCache.findIndex(cached => cached.pointerId === e.pointerId);
|
||||
if (index > -1) this.evCache.splice(index, 1);
|
||||
if (this.evCache.length < 2) this.prevDiff = -1;
|
||||
|
||||
if (this.evCache.length === 0) {
|
||||
// Threshold increased to 20px for mobile tap tolerance
|
||||
if (this.totalDragDist < 20) {
|
||||
if (this.activePtIdx === -1 && this.onClick) {
|
||||
const coords = this.getImgCoords(e.clientX, e.clientY);
|
||||
this.onClick(coords.x, coords.y, e);
|
||||
}
|
||||
}
|
||||
if(this.onMouseMove && e.target === this.canvas) {
|
||||
const coords = this.getImgCoords(e.clientX, e.clientY);
|
||||
this.onMouseMove(coords.x, coords.y);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', e => {
|
||||
if (this.drag && this.activePtIdx === -1 && e.shiftKey && this.onClick) {
|
||||
const coords = this.getImgCoords(e.clientX, e.clientY);
|
||||
this.onClick(coords.x, coords.y);
|
||||
}
|
||||
this.drag = false;
|
||||
this.isDragging = false;
|
||||
this.activePtIdx = -1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getImgCoords(screenX, screenY) {
|
||||
|
|
@ -109,40 +168,28 @@ class PanZoomCanvas {
|
|||
|
||||
setMirror(val) { this.isMirrored = val; this.draw(); }
|
||||
setImage(b) { this.bmp = b; this.draw(); }
|
||||
|
||||
setDimmed(isDimmed) {
|
||||
if(isDimmed) this.canvas.style.filter = "brightness(0.4) grayscale(100%)";
|
||||
else this.canvas.style.filter = "none";
|
||||
this.canvas.style.filter = isDimmed ? "brightness(0.4) grayscale(100%)" : "none";
|
||||
}
|
||||
|
||||
// [NEW] Fit Image to Container
|
||||
fit() {
|
||||
if(!this.bmp || !this.canvas) return;
|
||||
const vw = this.canvas.width;
|
||||
const vh = this.canvas.height;
|
||||
if (vw === 0 || vh === 0) return;
|
||||
|
||||
const iw = this.bmp.width;
|
||||
const ih = this.bmp.height;
|
||||
|
||||
// Scale to fit
|
||||
const scale = Math.min(vw / iw, vh / ih);
|
||||
|
||||
// Center
|
||||
const cx = (vw - iw * scale) / 2;
|
||||
const cy = (vh - ih * scale) / 2;
|
||||
|
||||
this.t = { x: cx, y: cy, k: scale };
|
||||
this.draw();
|
||||
}
|
||||
|
||||
draw() {
|
||||
if(!this.ctx) return;
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.ctx.save();
|
||||
this.ctx.translate(this.t.x, this.t.y);
|
||||
this.ctx.scale(this.t.k, this.t.k);
|
||||
|
||||
if (this.bmp) {
|
||||
this.ctx.save();
|
||||
if (this.isMirrored) {
|
||||
|
|
|
|||
|
|
@ -20,35 +20,35 @@
|
|||
|
||||
body { background: #f8fafc; color: #334155; line-height: 1.6; }
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
background: white;
|
||||
min-height: 100vh;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.05);
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
background: white;
|
||||
min-height: 100vh;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.05);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
h1 { color: #0f172a; margin-bottom: 0.5rem; border-bottom: 1px solid #e2e8f0; padding-bottom: 1rem; }
|
||||
h2 { color: #1e293b; margin-top: 2.5rem; margin-bottom: 1rem; font-size: 1.5rem; }
|
||||
h3 { color: #2563eb; margin-top: 1.5rem; font-size: 1.1rem; }
|
||||
|
||||
|
||||
.badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.75rem; font-weight: bold; background: #e2e8f0; color: #475569; }
|
||||
.badge.pro { background: #dcfce7; color: #166534; }
|
||||
|
||||
|
||||
ul, ol { padding-left: 1.5rem; margin-bottom: 1rem; }
|
||||
li { margin-bottom: 0.5rem; }
|
||||
|
||||
|
||||
.tip-box { background: #eff6ff; border-left: 4px solid #2563eb; padding: 1rem; margin: 1.5rem 0; border-radius: 0 4px 4px 0; font-size: 0.95rem; }
|
||||
.warn-box { background: #fff1f2; border-left: 4px solid #f43f5e; padding: 1rem; margin: 1.5rem 0; border-radius: 0 4px 4px 0; font-size: 0.95rem; }
|
||||
|
||||
|
||||
code { background: #f1f5f9; padding: 0.2rem 0.4rem; border-radius: 3px; font-family: monospace; font-size: 0.9em; color: #0f172a; border: 1px solid #e2e8f0; }
|
||||
|
||||
.nav-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; padding-bottom: 0.5rem; border-bottom: 1px solid #e2e8f0; }
|
||||
.back-link { text-decoration: none; color: #64748b; font-weight: 600; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.back-link:hover { color: #2563eb; }
|
||||
|
||||
|
||||
footer { margin-top: 4rem; padding-top: 2rem; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 0.8rem; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
html, body {
|
||||
height: auto !important;
|
||||
overflow-y: auto !important; /* Force scrollbar */
|
||||
display: block !important; /* Disable app flex layout */
|
||||
display: block !important; /* Disable app flex layout */
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: white;
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
}
|
||||
|
||||
:root { --hero-bg: #f8fafc; --text-main: #0f172a; --text-sub: #475569; --primary: #2563eb; }
|
||||
|
||||
|
||||
/* HERO SECTION */
|
||||
.hero {
|
||||
padding: 4rem 1rem 3rem;
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
}
|
||||
.hero h1 { font-size: 2.5rem; margin-bottom: 1rem; letter-spacing: -0.05em; color: var(--text-main); }
|
||||
.hero p { font-size: 1.2rem; color: var(--text-sub); max-width: 600px; margin: 0 auto 2rem; }
|
||||
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background: var(--primary); color: white;
|
||||
|
|
@ -63,10 +63,10 @@
|
|||
.tools-section { background: #f8fafc; padding: 3rem 1rem; border-top: 1px solid #e2e8f0; }
|
||||
.tools-container { max-width: 800px; margin: 0 auto; }
|
||||
.tools-title { text-align: center; margin-bottom: 2rem; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; font-size: 0.9rem; font-weight: 700; }
|
||||
|
||||
|
||||
.tool-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
|
||||
.tool-link {
|
||||
background: white; border: 1px solid #e2e8f0; border-radius: 0.5rem;
|
||||
.tool-link {
|
||||
background: white; border: 1px solid #e2e8f0; border-radius: 0.5rem;
|
||||
padding: 1rem; text-align: center; text-decoration: none; color: inherit;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
|
@ -75,10 +75,10 @@
|
|||
.tool-link span { font-size: 0.8rem; color: #64748b; }
|
||||
|
||||
footer.landing-footer {
|
||||
margin-top: 0;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
background: white;
|
||||
margin-top: 0;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
background: white;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
|
|
@ -97,9 +97,9 @@
|
|||
<div class="hero">
|
||||
<h1>PCB ReTrace</h1>
|
||||
<p>Digitize, document, and reverse engineer printed circuit boards. Map components, trace nets, and inspect layers. Runs entirely in your browser.</p>
|
||||
|
||||
|
||||
<a href="studio.html" class="cta-button">Launch Studio</a>
|
||||
|
||||
|
||||
<div class="secondary-links">
|
||||
<a href="guide.html">📖 User Guide & Documentation</a>
|
||||
<a href="https://github.com/dev-lab/pcb-retrace">GitHub Repo</a>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
</div>
|
||||
<div class="resistor-wrapper-outer"><div class="resistor-dumbbell-shape"><div class="bands-container" id="picker-area"></div></div></div>
|
||||
<div id="results" class="result-box"></div>
|
||||
<div class="app-footer">Copyright © 2025 Taras Greben — <a href="https://pcb.etaras.com">pcb.eTaras.com</a></div>
|
||||
<div class="app-footer">Copyright © 2025 Taras Greben — <a href="https://pcb.etaras.com">pcb.eTaras.com</a></div>
|
||||
</div>
|
||||
<div id="global-dropdown"></div>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,21 +7,21 @@ class Inspector {
|
|||
this.grid = document.getElementById('inspect-grid');
|
||||
this.sidebarList = document.getElementById('inspect-layers');
|
||||
this.activeNetEl = document.getElementById('inspect-active-net');
|
||||
|
||||
|
||||
this.viewers = {};
|
||||
this.visibleIds = new Set();
|
||||
this.activeNet = null;
|
||||
this.masterId = null;
|
||||
this.activeNet = null;
|
||||
this.masterId = null;
|
||||
this.netNodeCache = {}; // Cache for calculated node positions
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.sidebarList.innerHTML = '';
|
||||
|
||||
// Hide the "+ New Net" button for now
|
||||
// We find it by the onclick attribute since it doesn't have an ID in the HTML
|
||||
const newNetBtn = document.querySelector('button[onclick="inspector.startNewNet()"]');
|
||||
if(newNetBtn) newNetBtn.style.display = 'none';
|
||||
|
||||
// Hide the "+ New Net" button for now
|
||||
// We find it by the onclick attribute since it doesn't have an ID in the HTML
|
||||
const newNetBtn = document.querySelector('button[onclick="inspector.startNewNet()"]');
|
||||
if(newNetBtn) newNetBtn.style.display = 'none';
|
||||
|
||||
const imgs = [...bomImages].sort((a,b) => {
|
||||
const nA = a.name.toLowerCase(), nB = b.name.toLowerCase();
|
||||
|
|
@ -35,17 +35,17 @@ class Inspector {
|
|||
const row = document.createElement('div');
|
||||
// Force Grid layout for strict alignment of checkbox vs label
|
||||
row.style.cssText = "display:grid; grid-template-columns: 20px 1fr; align-items:center; gap:5px; color:#334155; font-size:0.85rem; border-bottom:1px solid #f1f5f9; padding-bottom:4px;";
|
||||
|
||||
|
||||
const chk = document.createElement('input');
|
||||
chk.type = 'checkbox';
|
||||
chk.checked = this.visibleIds.has(img.id);
|
||||
chk.onchange = () => this.toggleLayer(img.id, chk.checked);
|
||||
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.innerText = img.name;
|
||||
label.style.cssText = "white-space:nowrap; overflow:hidden; text-overflow:ellipsis;";
|
||||
label.title = img.name;
|
||||
|
||||
|
||||
row.appendChild(chk);
|
||||
row.appendChild(label);
|
||||
this.sidebarList.appendChild(row);
|
||||
|
|
@ -71,6 +71,15 @@ class Inspector {
|
|||
}
|
||||
|
||||
async renderGrid() {
|
||||
// --- 1. Snapshot State (Zoom/Pan) ---
|
||||
const savedStates = {};
|
||||
if (this.viewers) {
|
||||
Object.entries(this.viewers).forEach(([id, v]) => {
|
||||
// We clone the object to ensure safety, though not strictly required
|
||||
if (v.t) savedStates[id] = { ...v.t };
|
||||
});
|
||||
}
|
||||
|
||||
this.grid.innerHTML = '';
|
||||
this.viewers = {};
|
||||
|
||||
|
|
@ -79,7 +88,7 @@ class Inspector {
|
|||
return;
|
||||
}
|
||||
|
||||
// [FIX] Force Grid Layout Styles via JS to ensure they apply
|
||||
// Grid Layout
|
||||
this.grid.style.display = 'grid';
|
||||
this.grid.style.width = '100%';
|
||||
this.grid.style.height = '100%';
|
||||
|
|
@ -88,8 +97,15 @@ class Inspector {
|
|||
this.grid.style.background = '#000';
|
||||
|
||||
const count = this.visibleIds.size;
|
||||
this.grid.style.gridTemplateColumns = (count > 1) ? '1fr 1fr' : '1fr';
|
||||
this.grid.style.gridTemplateRows = (count > 2) ? '1fr 1fr' : '1fr';
|
||||
const isPortrait = window.innerHeight > window.innerWidth;
|
||||
|
||||
if (isPortrait) {
|
||||
this.grid.style.gridTemplateColumns = '1fr';
|
||||
this.grid.style.gridTemplateRows = `repeat(${count}, 1fr)`;
|
||||
} else {
|
||||
this.grid.style.gridTemplateColumns = (count > 1) ? '1fr 1fr' : '1fr';
|
||||
this.grid.style.gridTemplateRows = (count > 2) ? '1fr 1fr' : '1fr';
|
||||
}
|
||||
|
||||
if(!this.masterId || !this.visibleIds.has(this.masterId)) {
|
||||
this.masterId = this.visibleIds.values().next().value;
|
||||
|
|
@ -101,7 +117,7 @@ class Inspector {
|
|||
|
||||
const cell = document.createElement('div');
|
||||
cell.style.cssText = "position:relative; overflow:hidden; border:1px solid #334155; background:#000; width:100%; height:100%;";
|
||||
|
||||
|
||||
const cvs = document.createElement('canvas');
|
||||
cvs.id = `inspect-cvs-${id}`;
|
||||
cvs.style.display = 'block';
|
||||
|
|
@ -114,89 +130,68 @@ class Inspector {
|
|||
|
||||
this.grid.appendChild(cell);
|
||||
|
||||
let wasActiveBeforeDown = false;
|
||||
|
||||
const viewer = new PanZoomCanvas(cvs.id,
|
||||
(ctx, k) => this.drawOverlay(id, ctx, k),
|
||||
(x, y) => this.handleNodeClick(id, x, y),
|
||||
|
||||
async (x, y, e) => {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
if (wasActiveBeforeDown) {
|
||||
const hit = await this.handleNodeClick(id, x, y);
|
||||
if (!hit) {
|
||||
await this.handleAddNode(id, x, y);
|
||||
}
|
||||
}
|
||||
},
|
||||
(dx, dy, mode) => {
|
||||
// Hit Test: Return -1 to allow Panning
|
||||
if(mode === 'check') return -1;
|
||||
if(mode === 'check') return -1;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
viewer.onPointerDown = (e) => {
|
||||
if (e.isPrimary || e.button === 0) {
|
||||
wasActiveBeforeDown = (this.masterId === id);
|
||||
|
||||
if (this.masterId !== id) {
|
||||
this.masterId = id;
|
||||
}
|
||||
const pt = viewer.getImgCoords(e.clientX, e.clientY);
|
||||
this.syncCursors(id, pt.x, pt.y);
|
||||
}
|
||||
};
|
||||
|
||||
viewer.onMouseMove = (x, y) => {
|
||||
if(this.masterId === id) this.syncCursors(id, x, y);
|
||||
};
|
||||
|
||||
// Activation & Click Handling
|
||||
let downX = 0, downY = 0;
|
||||
let wasActiveBeforeDown = false; // Track state before click
|
||||
|
||||
cvs.addEventListener('mousedown', (e) => {
|
||||
if (e.button === 0) { // Left Click
|
||||
downX = e.clientX;
|
||||
downY = e.clientY;
|
||||
|
||||
// Check if we are clicking an already active window
|
||||
wasActiveBeforeDown = (this.masterId === id);
|
||||
|
||||
// Activate immediately to allow dragging/tracking
|
||||
if(this.masterId !== id) {
|
||||
this.masterId = id;
|
||||
}
|
||||
|
||||
const pt = viewer.getImgCoords(e.clientX, e.clientY);
|
||||
this.syncCursors(id, pt.x, pt.y);
|
||||
}
|
||||
});
|
||||
|
||||
// Right Click: Inactivate (Stop Tracking)
|
||||
cvs.oncontextmenu = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.masterId = null;
|
||||
this.cursorState = null;
|
||||
|
||||
// Clear cursors on all views
|
||||
Object.values(this.viewers).forEach(v => {
|
||||
v.cursorPos = null;
|
||||
v.setDimmed(false);
|
||||
v.draw();
|
||||
});
|
||||
};
|
||||
|
||||
cvs.addEventListener('mouseup', (e) => {
|
||||
if (e.button !== 0) return; // Left click only
|
||||
|
||||
const dist = Math.hypot(e.clientX - downX, e.clientY - downY);
|
||||
if (dist < 10) { // Click vs Drag
|
||||
// Only Add/Edit node if the window was ALREADY active.
|
||||
// If it wasn't, we just activated it in mousedown, so do nothing else.
|
||||
if (wasActiveBeforeDown) {
|
||||
const pt = viewer.getImgCoords(e.clientX, e.clientY);
|
||||
|
||||
// 1. Try to edit an existing node
|
||||
const hit = this.handleNodeClick(id, pt.x, pt.y);
|
||||
|
||||
// 2. If no node hit, try to add a new one
|
||||
if (!hit) {
|
||||
this.handleAddNode(id, pt.x, pt.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
cvs.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
this.masterId = null;
|
||||
this.cursorState = null;
|
||||
Object.values(this.viewers).forEach(v => {
|
||||
v.cursorPos = null; v.setDimmed(false); v.draw();
|
||||
});
|
||||
});
|
||||
|
||||
this.viewers[id] = viewer;
|
||||
|
||||
try {
|
||||
const bmp = await createImageBitmap(imgRec.blob);
|
||||
viewer.setImage(bmp);
|
||||
// Force sync canvas resolution to container size before fitting
|
||||
if (cell.clientWidth && cell.clientHeight) {
|
||||
viewer.canvas.width = cell.clientWidth;
|
||||
viewer.canvas.height = cell.clientHeight;
|
||||
}
|
||||
viewer.fit();
|
||||
|
||||
// --- 2. Restore State or Fit ---
|
||||
if (savedStates[id]) {
|
||||
viewer.t = savedStates[id];
|
||||
viewer.draw(); // Force redraw with restored transform
|
||||
} else {
|
||||
viewer.fit();
|
||||
}
|
||||
|
||||
if(imgRec.name.toLowerCase().includes('bot') && !imgRec.name.toLowerCase().includes('top')) {
|
||||
viewer.setMirror(true);
|
||||
|
|
@ -206,38 +201,38 @@ class Inspector {
|
|||
this.updateNetNodeCache();
|
||||
}
|
||||
|
||||
handleNodeClick(imgId, x, y) {
|
||||
if (!this.activeNet || !this.netNodeCache[imgId]) return false;
|
||||
const viewer = this.viewers[imgId];
|
||||
if (!viewer) return false;
|
||||
async handleNodeClick(imgId, x, y) {
|
||||
if (!this.activeNet || !this.netNodeCache[imgId]) return false;
|
||||
const viewer = this.viewers[imgId];
|
||||
if (!viewer) return false;
|
||||
|
||||
// Hit test in Screen Space
|
||||
const HIT_RADIUS = 20;
|
||||
const HIT_RADIUS = 20;
|
||||
const hit = this.netNodeCache[imgId].find(n => {
|
||||
const dist = Math.hypot(n.x - x, n.y - y);
|
||||
return (dist * viewer.t.k) < HIT_RADIUS;
|
||||
});
|
||||
|
||||
const hit = this.netNodeCache[imgId].find(n => {
|
||||
const dist = Math.hypot(n.x - x, n.y - y);
|
||||
return (dist * viewer.t.k) < HIT_RADIUS;
|
||||
});
|
||||
if (hit) {
|
||||
// Use generic input with a special Delete button
|
||||
const res = await requestInput("Edit Node", "Node Name", hit.label, {
|
||||
extraBtn: { label: 'Delete', value: '__DELETE__', class: 'danger' }
|
||||
});
|
||||
|
||||
if (hit) {
|
||||
const oldLabel = hit.label;
|
||||
const newLabel = prompt(`Edit Node "${oldLabel}"\n\nEnter new name to Rename.\nClear text and click OK to Delete.`, oldLabel);
|
||||
if (res === '__DELETE__') {
|
||||
const idx = this.activeNet.nodes.indexOf(hit.origNode);
|
||||
if (idx > -1) this.activeNet.nodes.splice(idx, 1);
|
||||
} else if (res) {
|
||||
hit.origNode.label = res;
|
||||
}
|
||||
|
||||
if (newLabel !== null) {
|
||||
const nodeIndex = this.activeNet.nodes.indexOf(hit.origNode);
|
||||
if (nodeIndex === -1) return true;
|
||||
|
||||
if (newLabel.trim() === "") {
|
||||
this.activeNet.nodes.splice(nodeIndex, 1); // Delete
|
||||
} else {
|
||||
this.activeNet.nodes[nodeIndex].label = newLabel.trim(); // Rename
|
||||
}
|
||||
this.updateNetUI();
|
||||
}
|
||||
return true; // Handled
|
||||
}
|
||||
return false; // Not handled
|
||||
}
|
||||
if (res) {
|
||||
this.updateNetUI();
|
||||
Object.values(this.viewers).forEach(v => v.draw());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
toggleLayer(id, isVisible) {
|
||||
if(isVisible) this.visibleIds.add(id);
|
||||
|
|
@ -245,22 +240,22 @@ class Inspector {
|
|||
this.renderGrid();
|
||||
}
|
||||
|
||||
// Load an existing net for editing
|
||||
loadNet(net) {
|
||||
// Deep copy to ensure "Cancel" works (discarding changes)
|
||||
this.activeNet = JSON.parse(JSON.stringify(net));
|
||||
this.updateNetUI();
|
||||
|
||||
// Ensure pins are visible immediately
|
||||
// We might need to ensure grid is rendered if not already
|
||||
if(Object.keys(this.viewers).length === 0) {
|
||||
this.renderGrid().then(() => {
|
||||
Object.values(this.viewers).forEach(v => v.draw());
|
||||
});
|
||||
} else {
|
||||
Object.values(this.viewers).forEach(v => v.draw());
|
||||
}
|
||||
}
|
||||
// Load an existing net for editing
|
||||
loadNet(net) {
|
||||
// Deep copy to ensure "Cancel" works (discarding changes)
|
||||
this.activeNet = JSON.parse(JSON.stringify(net));
|
||||
this.updateNetUI();
|
||||
|
||||
// Ensure pins are visible immediately
|
||||
// We might need to ensure grid is rendered if not already
|
||||
if(Object.keys(this.viewers).length === 0) {
|
||||
this.renderGrid().then(() => {
|
||||
Object.values(this.viewers).forEach(v => v.draw());
|
||||
});
|
||||
} else {
|
||||
Object.values(this.viewers).forEach(v => v.draw());
|
||||
}
|
||||
}
|
||||
|
||||
async updateNetNodeCache() {
|
||||
this.netNodeCache = {};
|
||||
|
|
@ -274,21 +269,21 @@ class Inspector {
|
|||
// 1. Add directly to the source image (Blue)
|
||||
if (this.visibleIds.has(node.imgId)) {
|
||||
this.netNodeCache[node.imgId].push({
|
||||
x: node.x, y: node.y, label: node.label,
|
||||
color: '#2563eb', isSource: true, origNode: node
|
||||
x: node.x, y: node.y, label: node.label,
|
||||
color: '#2563eb', isSource: true, origNode: node
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Project to other visible images (Green)
|
||||
// We need paths from the node's image to all other visible images
|
||||
const paths = await ImageGraph.solvePaths(node.imgId, this.cv, this.db);
|
||||
|
||||
|
||||
for (const p of paths) {
|
||||
if (this.visibleIds.has(p.id)) {
|
||||
const proj = this.cv.projectPoint(node.x, node.y, p.H);
|
||||
if (proj) {
|
||||
this.netNodeCache[p.id].push({
|
||||
x: proj.x, y: proj.y, label: node.label,
|
||||
x: proj.x, y: proj.y, label: node.label,
|
||||
color: '#4ade80', isSource: false, origNode: node
|
||||
});
|
||||
}
|
||||
|
|
@ -301,7 +296,7 @@ class Inspector {
|
|||
|
||||
async syncCursors(masterId, mx, my) {
|
||||
this.cursorState = { masterId, mx, my };
|
||||
|
||||
|
||||
for(const [id, viewer] of Object.entries(this.viewers)) {
|
||||
if(id === masterId) {
|
||||
viewer.setDimmed(false);
|
||||
|
|
@ -316,18 +311,18 @@ class Inspector {
|
|||
const pt = this.cv.projectPoint(mx, my, targetPath.H);
|
||||
if(pt) {
|
||||
viewer.cursorPos = pt;
|
||||
|
||||
|
||||
const w = viewer.bmp ? viewer.bmp.width : 1000;
|
||||
const h = viewer.bmp ? viewer.bmp.height : 1000;
|
||||
const inside = (pt.x >= 0 && pt.y >= 0 && pt.x <= w && pt.y <= h);
|
||||
|
||||
|
||||
viewer.setDimmed(!inside);
|
||||
|
||||
if (inside && viewer.bmp) {
|
||||
const k = viewer.t.k;
|
||||
const tx = viewer.t.x;
|
||||
const ty = viewer.t.y;
|
||||
|
||||
|
||||
const imgX = viewer.isMirrored ? (w - pt.x) : pt.x;
|
||||
const screenX = imgX * k + tx;
|
||||
const screenY = pt.y * k + ty;
|
||||
|
|
@ -362,90 +357,90 @@ class Inspector {
|
|||
}
|
||||
}
|
||||
|
||||
drawOverlay(id, ctx, k) {
|
||||
const viewer = this.viewers[id];
|
||||
if (!viewer) return;
|
||||
drawOverlay(id, ctx, k) {
|
||||
const viewer = this.viewers[id];
|
||||
if (!viewer) return;
|
||||
|
||||
const ik = 1/k; // Inverse zoom factor
|
||||
|
||||
// 1. Draw Cached Nodes as Pins
|
||||
if (this.netNodeCache[id]) {
|
||||
this.netNodeCache[id].forEach(n => {
|
||||
let drawX = n.x;
|
||||
if (viewer.isMirrored && viewer.bmp) drawX = viewer.bmp.width - n.x;
|
||||
const ik = 1/k; // Inverse zoom factor
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(drawX, n.y);
|
||||
ctx.scale(ik, ik); // Keep pin constant size on screen
|
||||
// 1. Draw Cached Nodes as Pins
|
||||
if (this.netNodeCache[id]) {
|
||||
this.netNodeCache[id].forEach(n => {
|
||||
let drawX = n.x;
|
||||
if (viewer.isMirrored && viewer.bmp) drawX = viewer.bmp.width - n.x;
|
||||
|
||||
// ROTATION: -45 degrees (Counter-Clockwise)
|
||||
// This aligns the "Bottom-Left" corner of our square to point straight down if the square is in the Top-Right quadrant.
|
||||
ctx.rotate(-Math.PI / 4);
|
||||
ctx.save();
|
||||
ctx.translate(drawX, n.y);
|
||||
ctx.scale(ik, ik); // Keep pin constant size on screen
|
||||
|
||||
// DRAW PIN SHAPE
|
||||
// We draw a square relative to the origin (0,0).
|
||||
// The Origin (0,0) is the Sharp Tip.
|
||||
// The square extends into x>0, y<0 (Visual Top-Right relative to rotation axis)
|
||||
// so that when rotated -45deg, it stands "Up" above the point.
|
||||
|
||||
const s = 20; // Size of the square side
|
||||
const r = 10; // Radius (50% of size)
|
||||
// ROTATION: -45 degrees (Counter-Clockwise)
|
||||
// This aligns the "Bottom-Left" corner of our square to point straight down if the square is in the Top-Right quadrant.
|
||||
ctx.rotate(-Math.PI / 4);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0); // Tip starts exactly on the node coordinate
|
||||
// DRAW PIN SHAPE
|
||||
// We draw a square relative to the origin (0,0).
|
||||
// The Origin (0,0) is the Sharp Tip.
|
||||
// The square extends into x>0, y<0 (Visual Top-Right relative to rotation axis)
|
||||
// so that when rotated -45deg, it stands "Up" above the point.
|
||||
|
||||
// Left Edge (going 'Up' in local coords) -> Top-Left Corner
|
||||
ctx.lineTo(0, -s + r);
|
||||
ctx.arcTo(0, -s, s, -s, r);
|
||||
const s = 20; // Size of the square side
|
||||
const r = 10; // Radius (50% of size)
|
||||
|
||||
// Top Edge -> Top-Right Corner
|
||||
ctx.lineTo(s - r, -s);
|
||||
ctx.arcTo(s, -s, s, 0, r);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0); // Tip starts exactly on the node coordinate
|
||||
|
||||
// Right Edge -> Bottom-Right Corner
|
||||
ctx.lineTo(s, -r);
|
||||
ctx.arcTo(s, 0, 0, 0, r);
|
||||
// Left Edge (going 'Up' in local coords) -> Top-Left Corner
|
||||
ctx.lineTo(0, -s + r);
|
||||
ctx.arcTo(0, -s, s, -s, r);
|
||||
|
||||
// Return to Tip
|
||||
ctx.lineTo(0, 0);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fillStyle = n.color; // Blue (#2563eb) or Green (#4ade80)
|
||||
ctx.fill();
|
||||
|
||||
// White Border
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeStyle = 'white';
|
||||
ctx.stroke();
|
||||
// Top Edge -> Top-Right Corner
|
||||
ctx.lineTo(s - r, -s);
|
||||
ctx.arcTo(s, -s, s, 0, r);
|
||||
|
||||
// LABEL
|
||||
// We want the text in the center of the "bulb".
|
||||
// The center of our square is at (s/2, -s/2).
|
||||
ctx.translate(s/2, -s/2);
|
||||
|
||||
// Rotate text back +45deg so it appears horizontal
|
||||
ctx.rotate(Math.PI / 4);
|
||||
// Right Edge -> Bottom-Right Corner
|
||||
ctx.lineTo(s, -r);
|
||||
ctx.arcTo(s, 0, 0, 0, r);
|
||||
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.fillText(n.label, 0, 0);
|
||||
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
// Return to Tip
|
||||
ctx.lineTo(0, 0);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fillStyle = n.color; // Blue (#2563eb) or Green (#4ade80)
|
||||
ctx.fill();
|
||||
|
||||
// White Border
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeStyle = 'white';
|
||||
ctx.stroke();
|
||||
|
||||
// LABEL
|
||||
// We want the text in the center of the "bulb".
|
||||
// The center of our square is at (s/2, -s/2).
|
||||
ctx.translate(s/2, -s/2);
|
||||
|
||||
// Rotate text back +45deg so it appears horizontal
|
||||
ctx.rotate(Math.PI / 4);
|
||||
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.fillText(n.label, 0, 0);
|
||||
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Cursor Crosshair (Unchanged)
|
||||
let cx, cy, color = '#ff0000';
|
||||
|
||||
|
||||
if(this.cursorState && this.cursorState.masterId === id) {
|
||||
cx = this.cursorState.mx; cy = this.cursorState.my;
|
||||
if(viewer.isMirrored && viewer.bmp) cx = viewer.bmp.width - cx;
|
||||
} else if (viewer.cursorPos) {
|
||||
cx = viewer.cursorPos.x; cy = viewer.cursorPos.y;
|
||||
if(viewer.isMirrored && viewer.bmp) cx = viewer.bmp.width - cx;
|
||||
color = '#facc15';
|
||||
color = '#facc15';
|
||||
}
|
||||
|
||||
if(cx !== undefined) {
|
||||
|
|
@ -464,76 +459,62 @@ class Inspector {
|
|||
this.updateNetUI();
|
||||
}
|
||||
|
||||
handleAddNode(imgId, x, y) {
|
||||
// Determine default label (start at P1 if no net exists)
|
||||
async handleAddNode(imgId, x, y) {
|
||||
const nextIdx = this.activeNet ? this.activeNet.nodes.length + 1 : 1;
|
||||
const defaultLabel = `P${nextIdx}`;
|
||||
|
||||
const label = prompt("Pad/Pin Name:", defaultLabel);
|
||||
|
||||
if(label) {
|
||||
if(!this.activeNet) {
|
||||
this.startNewNet();
|
||||
}
|
||||
const label = await requestInput("Add Node", "Pad/Pin Name", defaultLabel);
|
||||
|
||||
if(label) {
|
||||
if(!this.activeNet) this.startNewNet();
|
||||
this.activeNet.nodes.push({
|
||||
id: uuid(),
|
||||
imgId: imgId,
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
label: label
|
||||
id: uuid(), imgId: imgId, x: Math.round(x), y: Math.round(y), label: label
|
||||
});
|
||||
this.updateNetUI();
|
||||
Object.values(this.viewers).forEach(v => v.draw());
|
||||
}
|
||||
}
|
||||
|
||||
async saveNet() {
|
||||
if(!this.activeNet) return;
|
||||
|
||||
// Only prompt for name if it's a new net
|
||||
if (this.activeNet.isNew) {
|
||||
const name = prompt("Net Name:", this.activeNet.name);
|
||||
if(name) {
|
||||
this.activeNet.name = name;
|
||||
delete this.activeNet.isNew; // Remove flag before saving
|
||||
} else {
|
||||
return; // Cancelled by user in prompt
|
||||
}
|
||||
}
|
||||
async saveNet() {
|
||||
if(!this.activeNet) return;
|
||||
|
||||
this.activeNet.projectId = currentBomId;
|
||||
await this.db.addNet(this.activeNet);
|
||||
|
||||
this.activeNet = null;
|
||||
this.updateNetUI();
|
||||
|
||||
if(window.netManager) window.netManager.render();
|
||||
// Only prompt for name if it's a new net
|
||||
if (this.activeNet.isNew) {
|
||||
const name = await requestInput("Save Net", "Net Name", this.activeNet.name);
|
||||
if(name) {
|
||||
this.activeNet.name = name;
|
||||
delete this.activeNet.isNew;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.activeNet.projectId = currentBomId;
|
||||
await this.db.addNet(this.activeNet);
|
||||
this.activeNet = null;
|
||||
this.updateNetUI();
|
||||
if(window.netManager) window.netManager.render();
|
||||
}
|
||||
|
||||
// Return to Nets list
|
||||
history.back();
|
||||
}
|
||||
cancelNet() {
|
||||
this.activeNet = null;
|
||||
this.updateNetUI();
|
||||
history.back();
|
||||
}
|
||||
|
||||
cancelNet() {
|
||||
this.activeNet = null;
|
||||
this.updateNetUI();
|
||||
history.back();
|
||||
}
|
||||
updateNetUI() {
|
||||
if(!this.activeNet) {
|
||||
this.activeNetEl.style.display = 'none';
|
||||
} else {
|
||||
this.activeNetEl.style.cssText = "pointer-events:auto; background:rgba(15, 23, 42, 0.9); padding:4px 10px; border-radius:20px; border:1px solid #334155; display:flex; color:white; align-items:center; gap:8px; box-shadow:0 4px 6px rgba(0,0,0,0.2); backdrop-filter:blur(4px); font-size:0.85rem; height:auto;";
|
||||
|
||||
updateNetUI() {
|
||||
if(!this.activeNet) {
|
||||
this.activeNetEl.style.display = 'none';
|
||||
} else {
|
||||
this.activeNetEl.style.cssText = "pointer-events:auto; background:rgba(15, 23, 42, 0.9); padding:4px 10px; border-radius:20px; border:1px solid #334155; display:flex; color:white; align-items:center; gap:8px; box-shadow:0 4px 6px rgba(0,0,0,0.2); backdrop-filter:blur(4px); font-size:0.85rem; height:auto;";
|
||||
|
||||
this.activeNetEl.innerHTML = `
|
||||
<span style="font-weight:600; color:#4ade80; max-width:100px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${this.activeNet.name}</span>
|
||||
<span style="color:#94a3b8; border-left:1px solid #475569; padding-left:8px; font-size:0.8rem;">${this.activeNet.nodes.length}</span>
|
||||
<button class="primary sm-btn" style="padding:1px 8px; font-size:0.75rem; height:24px; min-height:0; line-height:1;" onclick="inspector.saveNet()">Save</button>
|
||||
<button class="danger sm-btn" style="padding:0; width:20px; height:20px; min-height:0; border-radius:50%; line-height:1; display:flex; align-items:center; justify-content:center;" onclick="inspector.cancelNet()">×</button>
|
||||
`;
|
||||
}
|
||||
this.updateNetNodeCache();
|
||||
}
|
||||
this.activeNetEl.innerHTML = `
|
||||
<span style="font-weight:600; color:#4ade80; max-width:100px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${this.activeNet.name}</span>
|
||||
<span style="color:#94a3b8; border-left:1px solid #475569; padding-left:8px; font-size:0.8rem;">${this.activeNet.nodes.length}</span>
|
||||
<button class="primary sm-btn" style="padding:1px 8px; font-size:0.75rem; height:24px; min-height:0; line-height:1;" onclick="inspector.saveNet()">Save</button>
|
||||
<button class="danger sm-btn" style="padding:0; width:20px; height:20px; min-height:0; border-radius:50%; line-height:1; display:flex; align-items:center; justify-content:center;" onclick="inspector.cancelNet()">×</button>
|
||||
`;
|
||||
}
|
||||
this.updateNetNodeCache();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
170
docs/nets.js
170
docs/nets.js
|
|
@ -5,62 +5,71 @@ class NetManager {
|
|||
this.db = db;
|
||||
}
|
||||
|
||||
async render() {
|
||||
const tbody = document.getElementById('nets-body');
|
||||
if(!tbody) return;
|
||||
async render() {
|
||||
const tbody = document.getElementById('nets-body');
|
||||
if(!tbody) return;
|
||||
|
||||
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center; color:#888;">Loading...</td></tr>';
|
||||
|
||||
const nets = await this.db.getNets();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if(nets.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center; padding:2rem; color:#94a3b8;">No nets defined. Go to "Inspect" to create one.</td></tr>';
|
||||
return;
|
||||
}
|
||||
// Safety check: If no board is loaded, clear the table
|
||||
if (typeof currentBomId === 'undefined' || !currentBomId) {
|
||||
tbody.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
nets.forEach(net => {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
tr.style.height = 'auto';
|
||||
tr.style.minHeight = '2.2rem';
|
||||
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center; color:#888;">Loading...</td></tr>';
|
||||
|
||||
// Target Icon for Editing
|
||||
const targetIcon = `<span style="cursor:pointer; margin-right:0.5rem;" onclick="netManager.editNet('${net.id}')" title="Edit Net on Board">🎯</span>`;
|
||||
const allNets = await this.db.getNets();
|
||||
|
||||
let nodesHtml = '';
|
||||
net.nodes.forEach((n, idx) => {
|
||||
nodesHtml += `<span class="net-chip" onclick="netManager.editNode('${net.id}', ${idx})" title="Edit Node">${n.label}</span>`;
|
||||
});
|
||||
|
||||
tr.innerHTML = `
|
||||
<td style="display:flex; align-items:center; vertical-align:top; height:auto;">
|
||||
${targetIcon}
|
||||
<input type="text" value="${net.name}" onchange="netManager.rename('${net.id}', this.value)" style="border:none; background:transparent; font-weight:bold; flex:1; min-width:0;">
|
||||
</td>
|
||||
|
||||
<!-- [FIX] Allow cell to expand freely -->
|
||||
<td style="white-space:normal; height:auto; overflow:visible;">
|
||||
<div style="display:flex; flex-wrap:wrap; gap:4px; padding:2px 0;">${nodesHtml}</div>
|
||||
</td>
|
||||
|
||||
<td style="text-align:right; vertical-align:top; height:auto;">
|
||||
<button class="danger sm-btn" onclick="netManager.delete('${net.id}')" title="Delete Net">🗑️</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
// --- FIX: Filter by Current Board ID ---
|
||||
const nets = allNets.filter(n => n.projectId === currentBomId);
|
||||
|
||||
// Edit Net in Inspector
|
||||
async editNet(id) {
|
||||
const net = await this.db._tx('nets', 'readonly', s => s.get(id));
|
||||
if(net) {
|
||||
switchView('inspect');
|
||||
// We use the global inspector instance
|
||||
if(window.inspector) window.inspector.loadNet(net);
|
||||
}
|
||||
}
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if(nets.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center; padding:2rem; color:#94a3b8;">No nets defined. Go to "Inspect" to create one.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
nets.forEach(net => {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
tr.style.height = 'auto';
|
||||
tr.style.minHeight = '2.2rem';
|
||||
|
||||
// Target Icon for Editing
|
||||
const targetIcon = `<span style="cursor:pointer; margin-right:0.5rem;" onclick="netManager.editNet('${net.id}')" title="Edit Net on Board">🎯</span>`;
|
||||
|
||||
let nodesHtml = '';
|
||||
net.nodes.forEach((n, idx) => {
|
||||
nodesHtml += `<span class="net-chip" onclick="netManager.editNode('${net.id}', ${idx})" title="Edit Node">${n.label}</span>`;
|
||||
});
|
||||
|
||||
tr.innerHTML = `
|
||||
<td style="display:flex; align-items:center; vertical-align:top; height:auto;">
|
||||
${targetIcon}
|
||||
<input type="text" value="${net.name}" onchange="netManager.rename('${net.id}', this.value)" style="border:none; background:transparent; font-weight:bold; flex:1; min-width:0;">
|
||||
</td>
|
||||
|
||||
<td style="white-space:normal; height:auto; overflow:visible;">
|
||||
<div style="display:flex; flex-wrap:wrap; gap:4px; padding:2px 0;">${nodesHtml}</div>
|
||||
</td>
|
||||
|
||||
<td style="text-align:right; vertical-align:top; height:auto;">
|
||||
<button class="danger sm-btn" onclick="netManager.delete('${net.id}')" title="Delete Net">🗑️</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
// Edit Net in Inspector
|
||||
async editNet(id) {
|
||||
const net = await this.db._tx('nets', 'readonly', s => s.get(id));
|
||||
if(net) {
|
||||
switchView('inspect');
|
||||
// We use the global inspector instance
|
||||
if(window.inspector) window.inspector.loadNet(net);
|
||||
}
|
||||
}
|
||||
|
||||
// Edit individual node
|
||||
async editNode(netId, nodeIdx) {
|
||||
|
|
@ -68,20 +77,17 @@ class NetManager {
|
|||
if(!net || !net.nodes[nodeIdx]) return;
|
||||
|
||||
const node = net.nodes[nodeIdx];
|
||||
// Simple action menu via Prompt/Confirm
|
||||
// 1. Ask for new name (Empty = Delete)
|
||||
const newName = prompt(`Edit node "${node.label}"\n\nEnter new name to Rename.\nClear text and press OK to Delete.`, node.label);
|
||||
|
||||
if(newName === null) return; // Cancelled
|
||||
|
||||
if(newName.trim() === '') {
|
||||
if(confirm("Delete this node?")) {
|
||||
net.nodes.splice(nodeIdx, 1);
|
||||
await this.db.addNet(net);
|
||||
this.render();
|
||||
}
|
||||
} else {
|
||||
net.nodes[nodeIdx].label = newName.trim();
|
||||
const res = await requestInput("Edit Node", "Node Name", node.label, {
|
||||
extraBtn: { label: 'Delete', value: '__DELETE__', class: 'danger' }
|
||||
});
|
||||
|
||||
if (res === '__DELETE__') {
|
||||
net.nodes.splice(nodeIdx, 1);
|
||||
await this.db.addNet(net);
|
||||
this.render();
|
||||
} else if (res) {
|
||||
net.nodes[nodeIdx].label = res;
|
||||
await this.db.addNet(net);
|
||||
this.render();
|
||||
}
|
||||
|
|
@ -125,10 +131,10 @@ class NetManager {
|
|||
'L': { lib: "Device", part: "L", desc: "Inductor" },
|
||||
'D': { lib: "Device", part: "D", desc: "Diode" },
|
||||
'TP': { lib: "Connector", part: "TestPoint", desc: "Test Point" },
|
||||
|
||||
|
||||
// --- AMBIGUOUS TYPES (Disabled by default) ---
|
||||
// 'Q': { lib: "Device", part: "Q_NPN_BEC", desc: "Transistor NPN" }, // Risk: Could be PNP, MOSFET, IGBT
|
||||
// 'J': { lib: "Connector", part: "Conn_01x02_Male", desc: "Connector" }, // Risk: Pin count unknown
|
||||
// 'Q': { lib: "Device", part: "Q_NPN_BEC", desc: "Transistor NPN" }, // Risk: Could be PNP, MOSFET, IGBT
|
||||
// 'J': { lib: "Connector", part: "Conn_01x02_Male", desc: "Connector" }, // Risk: Pin count unknown
|
||||
// 'CN': { lib: "Connector", part: "Conn_01x02_Male", desc: "Connector" }, // Risk: Pin count unknown
|
||||
};
|
||||
|
||||
|
|
@ -140,44 +146,44 @@ class NetManager {
|
|||
const val = c.value ? c.value : "~";
|
||||
const footprint = c.desc ? c.desc.replace(/"/g, '') : "";
|
||||
const tstamp = c.id ? c.id.substring(0, 8) : Math.floor(Math.random()*10000000).toString(16);
|
||||
|
||||
|
||||
// Detect Type from Prefix
|
||||
const prefix = (c.label.match(/^[A-Z]+/) || [""])[0].toUpperCase();
|
||||
|
||||
|
||||
// Lookup Library definition
|
||||
// Future TODO: Add logic here to check c.desc for keywords like "NPN", "MOSFET", etc.
|
||||
const def = COMPONENT_LIBRARY_MAP[prefix];
|
||||
|
||||
out += ` (comp (ref "${c.label}")\n`;
|
||||
out += ` (value "${val}")\n`;
|
||||
if(footprint) out += ` (footprint "${footprint}")\n`;
|
||||
|
||||
out += ` (comp (ref "${c.label}")\n`;
|
||||
out += ` (value "${val}")\n`;
|
||||
if(footprint) out += ` (footprint "${footprint}")\n`;
|
||||
|
||||
// Inject Library Source if we have a safe definition
|
||||
if(def) {
|
||||
out += ` (libsource (lib "${def.lib}") (part "${def.part}") (description "${def.desc}"))\n`;
|
||||
out += ` (property (name "Sheetname") (value "")) (property (name "Sheetfile") (value "${filename}.kicad_sch"))\n`;
|
||||
out += ` (libsource (lib "${def.lib}") (part "${def.part}") (description "${def.desc}"))\n`;
|
||||
out += ` (property (name "Sheetname") (value "")) (property (name "Sheetfile") (value "${filename}.kicad_sch"))\n`;
|
||||
}
|
||||
|
||||
out += ` (tstamp "${tstamp}")\n`;
|
||||
out += ` )\n`;
|
||||
|
||||
out += ` (tstamp "${tstamp}")\n`;
|
||||
out += ` )\n`;
|
||||
});
|
||||
out += " )\n";
|
||||
|
||||
// 3. Export Nets
|
||||
out += " (nets\n";
|
||||
nets.forEach((net, i) => {
|
||||
out += ` (net (code ${i+1}) (name "${net.name}")\n`;
|
||||
out += ` (net (code ${i+1}) (name "${net.name}")\n`;
|
||||
net.nodes.forEach(node => {
|
||||
const parts = node.label.split('.');
|
||||
if(parts.length === 2) {
|
||||
// Format: R1.2 (Ref R1, Pin 2)
|
||||
out += ` (node (ref "${parts[0]}") (pin "${parts[1]}"))\n`;
|
||||
out += ` (node (ref "${parts[0]}") (pin "${parts[1]}"))\n`;
|
||||
} else {
|
||||
// Fallback: TestPoints or direct names often use Pin 1
|
||||
out += ` (node (ref "${node.label}") (pin "1"))\n`;
|
||||
out += ` (node (ref "${node.label}") (pin "1"))\n`;
|
||||
}
|
||||
});
|
||||
out += " )\n";
|
||||
out += " )\n";
|
||||
});
|
||||
out += " )\n)\n";
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
</div>
|
||||
|
||||
<div id="results" class="result-box"></div>
|
||||
<div class="app-footer">Copyright © 2025 Taras Greben — <a href="https://pcb.etaras.com">pcb.eTaras.com</a></div>
|
||||
<div class="app-footer">Copyright © 2025 Taras Greben — <a href="https://pcb.etaras.com">pcb.eTaras.com</a></div>
|
||||
</div>
|
||||
|
||||
<div id="global-dropdown"></div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
/* stitch-editor.js */
|
||||
|
||||
// PanZoomCanvas class removed (moved to canvas-ui.js)
|
||||
|
||||
class StitchEditor {
|
||||
constructor(dbInstance, cvInstance) {
|
||||
// ... (Constructor remains the same) ...
|
||||
// It will now use the global PanZoomCanvas class
|
||||
this.db = dbInstance;
|
||||
this.cv = cvInstance;
|
||||
this.modal = document.getElementById('stitch-modal');
|
||||
|
|
@ -13,26 +9,31 @@ class StitchEditor {
|
|||
this.points = [];
|
||||
this.colors = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#00ffff', '#ff00ff', '#ffffff', '#ff8800', '#88ff00'];
|
||||
|
||||
// FIX: We pass 'null' as the 3rd argument (onClick) to prevent adding new points.
|
||||
// Points are only generated via setGrid() and moved via dragging.
|
||||
|
||||
this.viewSrc = new PanZoomCanvas('stitch-canvas-src',
|
||||
(c, k) => this.drawPts(c, k, 's'),
|
||||
(x, y) => this.addPt(x, y, 's'),
|
||||
null,
|
||||
(x, y, m, i) => this.hit(x, y, m, i, 's')
|
||||
);
|
||||
|
||||
this.viewDst = new PanZoomCanvas('stitch-canvas-dst',
|
||||
(c, k) => this.drawPts(c, k, 'd'),
|
||||
(x, y) => this.addPt(x, y, 'd'),
|
||||
null,
|
||||
(x, y, m, i) => this.hit(x, y, m, i, 'd')
|
||||
);
|
||||
|
||||
this.injectFlipControls();
|
||||
}
|
||||
|
||||
injectFlipControls() {
|
||||
|
||||
injectFlipControls() {
|
||||
const toolbar = document.querySelector('.stitch-toolbar');
|
||||
if(toolbar && !document.getElementById('btn-stitch-flip')) {
|
||||
const container = document.createElement('div');
|
||||
container.style.display = 'flex';
|
||||
container.style.gap = '5px';
|
||||
container.style.marginRight = 'auto';
|
||||
container.style.marginRight = 'auto';
|
||||
const btnFlip = document.createElement('button');
|
||||
btnFlip.id = 'btn-stitch-flip';
|
||||
btnFlip.className = 'secondary';
|
||||
|
|
@ -43,7 +44,7 @@ class StitchEditor {
|
|||
else toolbar.appendChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
toggleFlip(btn) {
|
||||
const newVal = !this.viewDst.isMirrored;
|
||||
this.viewDst.setMirror(newVal);
|
||||
|
|
@ -96,7 +97,7 @@ class StitchEditor {
|
|||
if (H && invH) {
|
||||
const rect = this.getOverlapRect(
|
||||
this.viewSrc.bmp.width, this.viewSrc.bmp.height,
|
||||
this.viewDst.bmp.width, this.viewDst.bmp.height,
|
||||
this.viewDst.bmp.width, this.viewDst.bmp.height,
|
||||
H
|
||||
);
|
||||
if (rect) {
|
||||
|
|
@ -148,12 +149,22 @@ class StitchEditor {
|
|||
const bS = await createImageBitmap(i1.blob);
|
||||
const bD = await createImageBitmap(i2.blob);
|
||||
|
||||
this.modal.style.display = 'flex'; // Trigger Layout
|
||||
|
||||
this.viewSrc.setImage(bS);
|
||||
this.viewDst.setImage(bD);
|
||||
|
||||
// Reset Zoom (Delay to ensure Canvas dimensions are updated by Observer)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.viewSrc.fit();
|
||||
this.viewDst.fit();
|
||||
});
|
||||
});
|
||||
|
||||
this.points = [];
|
||||
const existing = await this.db.getOverlapsForPair(srcImgId, dstImgId);
|
||||
|
||||
|
||||
let shouldFlip = false;
|
||||
|
||||
if (existing) {
|
||||
|
|
@ -194,7 +205,6 @@ class StitchEditor {
|
|||
}
|
||||
|
||||
this.refresh();
|
||||
this.modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
refresh() { this.viewSrc.draw(); this.viewDst.draw(); }
|
||||
|
|
@ -228,14 +238,6 @@ class StitchEditor {
|
|||
});
|
||||
}
|
||||
|
||||
addPt(x, y, side) {
|
||||
const p = { s:{x:0,y:0}, d:{x:0,y:0}, color: this.colors[this.points.length % this.colors.length] };
|
||||
if(side==='s') { p.s={x,y}; p.d={x:this.viewDst.bmp.width/2, y:this.viewDst.bmp.height/2}; }
|
||||
else { p.d={x,y}; p.s={x:this.viewSrc.bmp.width/2, y:this.viewSrc.bmp.height/2}; }
|
||||
this.points.push(p);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
hit(x, y, mode, idx, side) {
|
||||
if(mode==='check') {
|
||||
for(let i=this.points.length-1; i>=0; i--) {
|
||||
|
|
@ -246,9 +248,6 @@ class StitchEditor {
|
|||
} else if(mode==='move') {
|
||||
const pt = (side==='s')?this.points[idx].s:this.points[idx].d;
|
||||
pt.x+=x; pt.y+=y; this.refresh();
|
||||
} else if(mode==='delete') {
|
||||
const i=this.hit(x,y,'check',-1,side);
|
||||
if(i!==-1) { this.points.splice(i,1); this.refresh(); }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -275,4 +274,3 @@ class StitchEditor {
|
|||
window.history.back();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
100
docs/studio.html
100
docs/studio.html
|
|
@ -18,9 +18,9 @@
|
|||
<script src="cv-core.js"></script>
|
||||
<script src="stitch-editor.js"></script>
|
||||
<script src="studio.js" defer></script>
|
||||
<script src="canvas-ui.js"></script>
|
||||
<script src="nets.js"></script>
|
||||
<script src="inspector.js"></script>
|
||||
<script src="canvas-ui.js"></script>
|
||||
<script src="nets.js"></script>
|
||||
<script src="inspector.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="common.css">
|
||||
<style>
|
||||
|
|
@ -345,6 +345,9 @@
|
|||
/* STITCH MODAL */
|
||||
#stitch-modal .modal-content { max-width: 95vw; height: 90vh; }
|
||||
.stitch-split { display: flex; flex: 1; gap: 5px; overflow: hidden; background: #000; }
|
||||
@media (orientation: portrait) {
|
||||
.stitch-split { flex-direction: column; }
|
||||
}
|
||||
.stitch-pane { flex: 1; position: relative; overflow: hidden; border: 1px solid #444; }
|
||||
.stitch-pane canvas { display: block; width: 100%; height: 100%; cursor: grab; }
|
||||
.stitch-pane canvas:active { cursor: grabbing; }
|
||||
|
|
@ -362,39 +365,39 @@
|
|||
|
||||
/* Fix for Inspect Tab Layout */
|
||||
#view-inspect {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #0f172a;
|
||||
color: white;
|
||||
position: relative;
|
||||
overflow: hidden; /* Prevent body scroll bars */
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #0f172a;
|
||||
color: white;
|
||||
position: relative;
|
||||
overflow: hidden; /* Prevent body scroll bars */
|
||||
}
|
||||
|
||||
/* When active, force flex display to enable the column layout */
|
||||
#view-inspect.active {
|
||||
display: flex !important;
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
/* Ensure the grid fills remaining space */
|
||||
#inspect-grid {
|
||||
flex: 1;
|
||||
min-height: 0; /* Critical for nested flex scrolling/sizing */
|
||||
flex: 1;
|
||||
min-height: 0; /* Critical for nested flex scrolling/sizing */
|
||||
}
|
||||
|
||||
/* Add to common.css or styles */
|
||||
.net-chip {
|
||||
background: #e2e8f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
cursor: pointer;
|
||||
background: #e2e8f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.net-chip:hover {
|
||||
background: #cbd5e1;
|
||||
border-color: #94a3b8;
|
||||
background: #cbd5e1;
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
/* DRAG & DROP OVERLAY */
|
||||
|
|
@ -699,14 +702,14 @@
|
|||
<button class="icon-btn secondary" onclick="openBoardSettings()" title="Board Settings">✎</button>
|
||||
</div>
|
||||
</div>
|
||||
<a href="guide.html" target="_blank" class="icon-btn secondary" style="text-decoration:none; margin-left:5px; display:flex; align-items:center; justify-content:center;" title="Help / Guide">?</a>
|
||||
<a href="guide.html" target="_blank" class="icon-btn secondary" style="text-decoration:none; margin-left:5px; display:flex; align-items:center; justify-content:center;" title="Help / Guide">?</a>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div id="tab-list" class="tab-btn active" onclick="switchView('list')">📋 BOM</div>
|
||||
<div id="tab-map" class="tab-btn" onclick="switchView('map')">🖼️ Images</div>
|
||||
<div id="tab-nets" class="tab-btn" onclick="switchView('nets')">🕸️ Nets</div>
|
||||
<div id="tab-inspect" class="tab-btn" onclick="switchView('inspect')">🔍 Inspect</div>
|
||||
<div id="tab-nets" class="tab-btn" onclick="switchView('nets')">🕸️ Nets</div>
|
||||
<div id="tab-inspect" class="tab-btn" onclick="switchView('inspect')">🔍 Inspect</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -831,10 +834,10 @@
|
|||
|
||||
<!-- 4. INSPECT VIEW (Fixed Layout & Toolbar) -->
|
||||
<div id="view-inspect" class="view-section">
|
||||
|
||||
|
||||
<!-- Single Floating Toolbar Container -->
|
||||
<div id="inspect-toolbar" style="position:absolute; top:0.5rem; left:0.5rem; right:0.5rem; z-index:100; display:flex; gap:0.5rem; align-items:center; pointer-events:none;">
|
||||
|
||||
|
||||
<!-- 1. Layers Dropdown -->
|
||||
<div style="pointer-events:auto; padding:0 0.5rem; background:rgba(255,255,255,0.95); box-shadow:0 2px 10px rgba(0,0,0,0.3); border-radius:4px; height:2rem; display:flex; align-items:center;">
|
||||
<details id="inspect-layers-details" style="position:relative;">
|
||||
|
|
@ -847,11 +850,11 @@
|
|||
<div id="inspect-active-net" style="pointer-events:auto; background:rgba(30, 41, 59, 0.95); padding:0 0.8rem; border-radius:4px; border:1px solid #475569; display:none; color:white; height:2rem; align-items:center; box-shadow:0 2px 5px rgba(0,0,0,0.5);">
|
||||
<!-- Injected by JS -->
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 3. New Net Button -->
|
||||
<button class="primary sm-btn" style="pointer-events:auto; box-shadow:0 2px 5px rgba(0,0,0,0.5); height:2rem;" onclick="inspector.startNewNet()">+ New Net</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Full Screen Grid -->
|
||||
<div id="inspect-grid">
|
||||
<div style="display:flex; align-items:center; justify-content:center; color:#64748b; height:100%;">Select layers to inspect</div>
|
||||
|
|
@ -931,7 +934,7 @@
|
|||
</div>
|
||||
<div class="stitch-toolbar" style="display:flex; align-items:center; gap:8px;">
|
||||
<!-- JS Injects Flip Button on Left -->
|
||||
|
||||
|
||||
<div style="flex:1; text-align:center;">
|
||||
<button class="secondary sm-btn" onclick="stitchEditor.setGrid(2)" title="Set 4 Points (Corners)">Grid 2x2</button>
|
||||
<button class="secondary sm-btn" onclick="stitchEditor.setGrid(3)" title="Set 9 Points (Recommended)">Grid 3x3</button>
|
||||
|
|
@ -1044,6 +1047,27 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GENERIC INPUT MODAL -->
|
||||
<div style="display:none; z-index:1150;" id="generic-input-modal" class="modal-overlay">
|
||||
<div class="modal-content" style="height:auto; max-width:300px; background:#fff; border:1px solid #cbd5e1;">
|
||||
<div class="modal-header">
|
||||
<h3 style="margin:0" id="gim-title">Input</h3>
|
||||
<button class="close-btn" onclick="document.getElementById('generic-input-modal').style.display='none'">×</button>
|
||||
</div>
|
||||
<div style="padding:1.5rem; display:flex; flex-direction:column; gap:1rem;">
|
||||
<div>
|
||||
<label id="gim-label" style="font-size:0.8rem; font-weight:700; color:#64748b; text-transform:uppercase; margin-bottom:0.2rem; display:block;">Value</label>
|
||||
<input type="text" id="gim-input" style="width:100%; padding:0.6rem; border:1px solid #cbd5e1; border-radius:0.3rem; font-size:1rem;">
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; gap:10px;" id="gim-footer">
|
||||
<button id="gim-extra-btn" class="danger" style="display:none; padding:0.6rem 1rem; margin-right:auto;"></button>
|
||||
<button id="gim-cancel-btn" class="secondary" style="padding:0.6rem 1rem;">Cancel</button>
|
||||
<button id="gim-ok-btn" class="primary" style="padding:0.6rem 1rem;">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MOBILE ACTION SHEET -->
|
||||
<div style="display:none; align-items:flex-end;" id="mobile-menu-modal" class="modal-overlay" onclick="this.style.display='none'">
|
||||
<div class="modal-content" style="width:100%; max-width:100%; border-radius:1rem 1rem 0 0; margin:0; animation: slideUp 0.2s ease-out;" onclick="event.stopPropagation()">
|
||||
|
|
@ -1176,7 +1200,7 @@ const ImageImporter = {
|
|||
sourceImage: null,
|
||||
currentRotation: 0,
|
||||
isCameraSession: false,
|
||||
|
||||
|
||||
// View State (Zoom/Pan)
|
||||
view: { scale: 1, x: 0, y: 0 },
|
||||
lastDist: 0, // For pinch zoom
|
||||
|
|
@ -1194,13 +1218,13 @@ const ImageImporter = {
|
|||
const start = (e) => this.handleDragStart(e);
|
||||
const move = (e) => this.handleDragMove(e);
|
||||
const end = () => this.handleDragEnd();
|
||||
|
||||
|
||||
this.canvas.addEventListener('mousedown', start);
|
||||
this.canvas.addEventListener('touchstart', start, {passive: false});
|
||||
|
||||
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('touchmove', move, {passive: false});
|
||||
|
||||
|
||||
window.addEventListener('mouseup', end);
|
||||
window.addEventListener('touchend', end);
|
||||
|
||||
|
|
@ -1298,7 +1322,7 @@ const ImageImporter = {
|
|||
this.canvas.height = this.video.videoHeight;
|
||||
this.canvas.getContext('2d').drawImage(this.video, 0, 0);
|
||||
this.stopStream();
|
||||
|
||||
|
||||
const now = new Date();
|
||||
const ts = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
this.nameInput.value = `IMG_${ts}`;
|
||||
|
|
@ -1381,7 +1405,7 @@ const ImageImporter = {
|
|||
handleDragStart: function(e) {
|
||||
// Pinch Start (Mobile Zoom)
|
||||
if (e.type === 'touchstart' && e.touches.length > 1) {
|
||||
this.isDragging = false;
|
||||
this.isDragging = false;
|
||||
this.isPanning = false;
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
|
|
@ -1499,10 +1523,10 @@ const ImageImporter = {
|
|||
let name = this.nameInput.value.trim() || "New Image";
|
||||
name = name.replace(/\.(jpg|jpeg|png)$/i, "");
|
||||
const rect = this.cropRect;
|
||||
|
||||
|
||||
// Clean up view before saving (so render is pure)
|
||||
this.cropRect = null;
|
||||
this.resetView();
|
||||
this.resetView();
|
||||
this.render();
|
||||
|
||||
let finalCanvas = this.canvas;
|
||||
|
|
|
|||
315
docs/studio.js
315
docs/studio.js
|
|
@ -106,16 +106,20 @@ async function init() {
|
|||
db = new PcbDatabase();
|
||||
await db.init();
|
||||
|
||||
if (!history.state) {
|
||||
history.replaceState({ context: 'list' }, "", "");
|
||||
}
|
||||
|
||||
cvManager = new CVManager();
|
||||
cvManager.init(); // Lazy load (don't await)
|
||||
|
||||
// Init net managers
|
||||
netManager = new NetManager(db);
|
||||
inspector = new Inspector(db, cvManager);
|
||||
// Init net managers
|
||||
netManager = new NetManager(db);
|
||||
inspector = new Inspector(db, cvManager);
|
||||
|
||||
// [CRITICAL] Expose to window so inline HTML onclicks (generated by Inspector) work
|
||||
window.netManager = netManager;
|
||||
window.inspector = inspector;
|
||||
// [CRITICAL] Expose to window so inline HTML onclicks (generated by Inspector) work
|
||||
window.netManager = netManager;
|
||||
window.inspector = inspector;
|
||||
|
||||
stitchEditor = new StitchEditor(db, cvManager);
|
||||
|
||||
|
|
@ -218,7 +222,29 @@ async function switchBom() {
|
|||
}
|
||||
|
||||
async function loadProjectData() {
|
||||
if (!currentBomId) { bomData = []; bomImages = []; renderList(); return; }
|
||||
// Helper to refresh other tabs
|
||||
const refreshViews = async () => {
|
||||
if (window.netManager) window.netManager.render();
|
||||
if (window.inspector) {
|
||||
// Clear previous selection as IDs are no longer valid
|
||||
window.inspector.visibleIds.clear();
|
||||
window.inspector.activeNet = null; // Reset active net
|
||||
await window.inspector.init(); // Rebuild sidebar & grid
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentBomId) {
|
||||
bomData = [];
|
||||
bomImages = [];
|
||||
renderList();
|
||||
|
||||
// FIX: Clear other views if no board selected
|
||||
const imgSel = document.getElementById('image-select');
|
||||
if(imgSel) imgSel.innerHTML = '<option disabled selected>No Images</option>';
|
||||
clearMap();
|
||||
await refreshViews();
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = bomList.find(p=>p.id===currentBomId);
|
||||
if (meta) {
|
||||
|
|
@ -229,6 +255,7 @@ async function loadProjectData() {
|
|||
bomData = await db.getComponents(currentBomId);
|
||||
bomImages = await db.getImages(currentBomId);
|
||||
|
||||
// Convert raw blobs to Image objects for cache if needed (lazy loaded usually)
|
||||
for (const img of bomImages) {
|
||||
if (/\.(jpg|jpeg|png|webp)$/i.test(img.name)) {
|
||||
img.name = img.name.replace(/\.[^/.]+$/, "");
|
||||
|
|
@ -239,24 +266,30 @@ async function loadProjectData() {
|
|||
const imgSel = document.getElementById('image-select');
|
||||
imgSel.innerHTML = '';
|
||||
if(bomImages.length > 0) {
|
||||
bomImages.forEach(img => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = img.id; opt.innerText = img.name;
|
||||
imgSel.appendChild(opt);
|
||||
});
|
||||
if(!currentImgId || !bomImages.find(i=>i.id===currentImgId)) currentImgId = bomImages[0].id;
|
||||
imgSel.value = currentImgId;
|
||||
if(document.getElementById('view-map').classList.contains('active')) showImage(currentImgId);
|
||||
bomImages.forEach(img => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = img.id; opt.innerText = img.name;
|
||||
imgSel.appendChild(opt);
|
||||
});
|
||||
if(!currentImgId || !bomImages.find(i=>i.id===currentImgId)) currentImgId = bomImages[0].id;
|
||||
imgSel.value = currentImgId;
|
||||
|
||||
// Only load the image if we are actually on the map tab
|
||||
if(document.getElementById('view-map').classList.contains('active')) showImage(currentImgId);
|
||||
} else {
|
||||
currentImgId = null;
|
||||
imgSel.innerHTML = '<option disabled selected>No Images</option>';
|
||||
clearMap();
|
||||
currentImgId = null;
|
||||
imgSel.innerHTML = '<option disabled selected>No Images</option>';
|
||||
clearMap();
|
||||
}
|
||||
|
||||
renderList();
|
||||
|
||||
// Refresh Nets and Inspector with new data
|
||||
await refreshViews();
|
||||
}
|
||||
|
||||
async function createNewDevice() {
|
||||
const n = prompt("New Device Name:");
|
||||
const n = await requestInput("New Device", "Device Name", "");
|
||||
if (n) {
|
||||
const id = uuid();
|
||||
await db.addDevice({ id, name: n });
|
||||
|
|
@ -271,9 +304,9 @@ async function createNewDevice() {
|
|||
|
||||
async function createNewBom() {
|
||||
if (!currentDeviceId) return alert("Select a device first.");
|
||||
const n=prompt("New Board Name:");
|
||||
const n = await requestInput("New Board", "Board Name", "");
|
||||
if(n) {
|
||||
const sec = prompt("Section (Optional):") || "";
|
||||
const sec = await requestInput("Section", "Group (Optional)", "") || "";
|
||||
const id=uuid();
|
||||
await db.addProject({id, deviceId: currentDeviceId, name:n, section: sec, lastModified:Date.now(), sortMode:'none'});
|
||||
currentBomId=id;
|
||||
|
|
@ -437,9 +470,9 @@ async function exportDeviceZIP() {
|
|||
const dev = deviceList.find(d => d.id === currentDeviceId);
|
||||
const boms = await db.getProjectsByDevice(currentDeviceId);
|
||||
const zip = new JSZip();
|
||||
|
||||
// Fetch ALL nets once (since we don't have an index, we filter in JS)
|
||||
const allNets = await db.getNets();
|
||||
|
||||
// Fetch ALL nets once (since we don't have an index, we filter in JS)
|
||||
const allNets = await db.getNets();
|
||||
|
||||
// 1. Generate README
|
||||
const readmeContent = `PCB ReTrace Data Export
|
||||
|
|
@ -466,9 +499,9 @@ HOW TO USE:
|
|||
for (const bom of boms) {
|
||||
const comps = await db.getComponents(bom.id);
|
||||
const imgs = await db.getImages(bom.id);
|
||||
|
||||
// Filter nets for this specific board
|
||||
const boardNets = allNets.filter(n => n.projectId === bom.id);
|
||||
|
||||
// Filter nets for this specific board
|
||||
const boardNets = allNets.filter(n => n.projectId === bom.id);
|
||||
|
||||
const overlapsMap = new Map();
|
||||
for(const img of imgs) {
|
||||
|
|
@ -484,7 +517,7 @@ HOW TO USE:
|
|||
components: cleanComps,
|
||||
images: imgMeta,
|
||||
overlaps: Array.from(overlapsMap.values()),
|
||||
nets: boardNets // Add Nets to manifest
|
||||
nets: boardNets // Add Nets to manifest
|
||||
});
|
||||
|
||||
// Image Loop
|
||||
|
|
@ -746,8 +779,8 @@ async function restoreDevice(manifest, zip) {
|
|||
for (const lb of localBoards) {
|
||||
if (!manifestBoardIds.has(lb.id)) {
|
||||
await db.deleteProject(lb.id);
|
||||
// Note: deleteProject doesn't currently delete Nets automatically in the DB class
|
||||
// We should ideally clean them up, but for now we focus on the restore logic.
|
||||
// Note: deleteProject doesn't currently delete Nets automatically in the DB class
|
||||
// We should ideally clean them up, but for now we focus on the restore logic.
|
||||
console.log(`Pruned Board: ${lb.name}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -801,13 +834,13 @@ async function restoreDevice(manifest, zip) {
|
|||
});
|
||||
}
|
||||
|
||||
// 4. Prune Nets [NEW]
|
||||
const allNets = await db.getNets();
|
||||
const localNets = allNets.filter(n => n.projectId === boardId);
|
||||
const importNetIds = new Set((boardData.nets || []).map(n => n.id));
|
||||
for(const ln of localNets) {
|
||||
if(!importNetIds.has(ln.id)) await db.deleteNet(ln.id);
|
||||
}
|
||||
// 4. Prune Nets [NEW]
|
||||
const allNets = await db.getNets();
|
||||
const localNets = allNets.filter(n => n.projectId === boardId);
|
||||
const importNetIds = new Set((boardData.nets || []).map(n => n.id));
|
||||
for(const ln of localNets) {
|
||||
if(!importNetIds.has(ln.id)) await db.deleteNet(ln.id);
|
||||
}
|
||||
|
||||
} else {
|
||||
mergedBoards++;
|
||||
|
|
@ -863,19 +896,19 @@ async function restoreDevice(manifest, zip) {
|
|||
}
|
||||
}
|
||||
|
||||
// Nets [NEW]
|
||||
if (boardData.nets) {
|
||||
for (const net of boardData.nets) {
|
||||
// Ensure correct association
|
||||
net.projectId = boardId;
|
||||
if (isNewer) {
|
||||
await db.addNet(net);
|
||||
} else {
|
||||
const existingNet = await db._tx('nets', 'readonly', s => s.get(net.id));
|
||||
if (!existingNet) await db.addNet(net);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Nets [NEW]
|
||||
if (boardData.nets) {
|
||||
for (const net of boardData.nets) {
|
||||
// Ensure correct association
|
||||
net.projectId = boardId;
|
||||
if (isNewer) {
|
||||
await db.addNet(net);
|
||||
} else {
|
||||
const existingNet = await db._tx('nets', 'readonly', s => s.get(net.id));
|
||||
if (!existingNet) await db.addNet(net);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deviceList = await db.getDevices();
|
||||
|
|
@ -928,13 +961,13 @@ async function processImportData(data, zipObj) {
|
|||
});
|
||||
}
|
||||
|
||||
// 4. Nets [NEW]
|
||||
const allNets = await db.getNets();
|
||||
const localNets = allNets.filter(n => n.projectId === boardId);
|
||||
const importNetIds = new Set((data.nets || []).map(n => n.id));
|
||||
for(const ln of localNets) {
|
||||
if(!importNetIds.has(ln.id)) await db.deleteNet(ln.id);
|
||||
}
|
||||
// 4. Nets [NEW]
|
||||
const allNets = await db.getNets();
|
||||
const localNets = allNets.filter(n => n.projectId === boardId);
|
||||
const importNetIds = new Set((data.nets || []).map(n => n.id));
|
||||
for(const ln of localNets) {
|
||||
if(!importNetIds.has(ln.id)) await db.deleteNet(ln.id);
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log("Import is older/same. Merging missing items only.");
|
||||
|
|
@ -994,18 +1027,18 @@ async function processImportData(data, zipObj) {
|
|||
}
|
||||
}
|
||||
|
||||
// Nets [NEW]
|
||||
if (data.nets) {
|
||||
for (const net of data.nets) {
|
||||
net.projectId = boardId;
|
||||
if (isNewer) {
|
||||
await db.addNet(net);
|
||||
} else {
|
||||
const existing = await db._tx('nets', 'readonly', s => s.get(net.id));
|
||||
if (!existing) await db.addNet(net);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Nets [NEW]
|
||||
if (data.nets) {
|
||||
for (const net of data.nets) {
|
||||
net.projectId = boardId;
|
||||
if (isNewer) {
|
||||
await db.addNet(net);
|
||||
} else {
|
||||
const existing = await db._tx('nets', 'readonly', s => s.get(net.id));
|
||||
if (!existing) await db.addNet(net);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await loadDeviceBoms();
|
||||
}
|
||||
|
|
@ -1023,9 +1056,9 @@ async function exportZIP() {
|
|||
const meta = bomList.find(x=>x.id===currentBomId);
|
||||
const images = await db.getImages(currentBomId);
|
||||
|
||||
// Fetch and filter Nets
|
||||
const allNets = await db.getNets();
|
||||
const boardNets = allNets.filter(n => n.projectId === currentBomId);
|
||||
// Fetch and filter Nets
|
||||
const allNets = await db.getNets();
|
||||
const boardNets = allNets.filter(n => n.projectId === currentBomId);
|
||||
|
||||
const overlapsMap = new Map();
|
||||
for(const img of images) {
|
||||
|
|
@ -1056,7 +1089,7 @@ HOW TO USE:
|
|||
components: cleanComps,
|
||||
images: imgMeta,
|
||||
overlaps: overlaps,
|
||||
nets: boardNets, // Add Nets
|
||||
nets: boardNets, // Add Nets
|
||||
version: DB_VER,
|
||||
source: "pcb.etaras.com"
|
||||
};
|
||||
|
|
@ -1141,10 +1174,10 @@ function switchView(v) {
|
|||
if(!document.getElementById('map-content').querySelector('.pcb-image')) showImage(currentImgId);
|
||||
else { setTimeout(() => { if(mapState.scale === 1 && mapState.x === 0 && mapState.y === 0) fitMap(); renderPins(); }, 50); }
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (v === 'nets') {
|
||||
if(window.netManager) window.netManager.render();
|
||||
}
|
||||
}
|
||||
else if (v === 'inspect') {
|
||||
if(window.inspector) window.inspector.init();
|
||||
}
|
||||
|
|
@ -1183,9 +1216,9 @@ function renderList() {
|
|||
view.forEach(p => {
|
||||
const pinIcon = (p.x !== undefined) ? `<span style="cursor:pointer" onclick="locateComponent('${p.imgId}', ${p.x}, ${p.y}); event.stopPropagation();">🎯</span>` : '';
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
|
||||
// [NEW] Assign ID for lookup
|
||||
tr.dataset.id = p.id;
|
||||
tr.dataset.id = p.id;
|
||||
|
||||
tr.onclick = (e) => {
|
||||
if(e.target.tagName==='BUTTON' || e.target.closest('button')) return;
|
||||
|
|
@ -1449,7 +1482,8 @@ window.saveProcessedImageToDB = saveProcessedImageToDB;
|
|||
|
||||
async function uploadImage(input) {
|
||||
const file = input.files[0]; if(!file) return;
|
||||
const name = prompt("Image Name:", file.name);
|
||||
const name = await requestInput("Upload Image", "Image Name", file.name);
|
||||
|
||||
if(!name) return;
|
||||
|
||||
const id = uuid(); // Capture ID to use later
|
||||
|
|
@ -1899,10 +1933,10 @@ async function fillFormFromData(c) {
|
|||
function copyPartToForm(idx) { const c = bomData[idx]; document.getElementById('inp-value').value = c.value; document.getElementById('inp-desc').value = c.desc; }
|
||||
|
||||
// --- CONNECTIONS UI ---
|
||||
// Delete connection function
|
||||
// Delete connection function
|
||||
async function deleteConnection(targetId) {
|
||||
if(!currentImgId || !targetId) return;
|
||||
|
||||
|
||||
const targetImg = bomImages.find(i => i.id === targetId);
|
||||
const name = targetImg ? targetImg.name : "this image";
|
||||
|
||||
|
|
@ -1919,24 +1953,24 @@ async function renderConnectionsList() {
|
|||
if(!currentImgId) return;
|
||||
const curImg = bomImages.find(i => i.id === currentImgId);
|
||||
if(!curImg) return;
|
||||
|
||||
|
||||
const title = document.getElementById('conn-src-name');
|
||||
if(title) title.innerText = curImg.name;
|
||||
|
||||
|
||||
const list = document.getElementById('conn-list');
|
||||
if(!list) return;
|
||||
|
||||
|
||||
list.innerHTML = '<div style="text-align:center;color:#888">Loading...</div>';
|
||||
|
||||
const overlaps = await db.getOverlapsForImage(currentImgId);
|
||||
const others = bomImages.filter(i => i.id !== currentImgId);
|
||||
|
||||
|
||||
list.innerHTML = '';
|
||||
if(others.length === 0) {
|
||||
list.innerHTML = '<div style="padding:1rem; text-align:center; background:#eee; border-radius:4px;">No other images to stitch with.</div>';
|
||||
return;
|
||||
if(others.length === 0) {
|
||||
list.innerHTML = '<div style="padding:1rem; text-align:center; background:#eee; border-radius:4px;">No other images to stitch with.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
others.forEach(img => {
|
||||
const ov = overlaps.find(o => o.fromImageId === img.id || o.toImageId === img.id);
|
||||
const row = document.createElement('div');
|
||||
|
|
@ -1947,7 +1981,7 @@ async function renderConnectionsList() {
|
|||
if(ov) {
|
||||
if(ov.isManual) statusBadge = `<span style="font-size:0.75rem; background:#dcfce7; color:#166534; padding:2px 6px; border-radius:4px;">Manual (${ov.matchCount} pts)</span>`;
|
||||
else statusBadge = `<span style="font-size:0.75rem; background:#e0f2fe; color:#0369a1; padding:2px 6px; border-radius:4px;">Auto-CV</span>`;
|
||||
|
||||
|
||||
// Show Delete + Edit buttons
|
||||
actions = `<div style="display:flex; gap:5px;">
|
||||
<button class="danger sm-btn" style="padding:0 8px;" onclick="deleteConnection('${img.id}')" title="Remove Connection">🗑️</button>
|
||||
|
|
@ -2241,10 +2275,10 @@ const NavManager = {
|
|||
} else {
|
||||
// Default to List
|
||||
switchView('list', true);
|
||||
|
||||
|
||||
// Restore Tool if needed
|
||||
if (ctx === 'tool') this.restoreTool();
|
||||
|
||||
|
||||
// Restore Modals
|
||||
if (ctx && ctx.endsWith('-modal')) {
|
||||
const el = document.getElementById(ctx);
|
||||
|
|
@ -2337,13 +2371,108 @@ const NavManager = {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic Input Dialog Helper
|
||||
* @param {string} title - Modal Title
|
||||
* @param {string} label - Input Field Label
|
||||
* @param {string} val - Default Value
|
||||
* @param {object} opts - { extraBtn: {label, value, class} }
|
||||
* @returns Promise<string|null> - Returns input value, extraBtn value, or null (cancel)
|
||||
*/
|
||||
function requestInput(title, label, val, opts = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.getElementById('generic-input-modal');
|
||||
const inp = document.getElementById('gim-input');
|
||||
const extraBtn = document.getElementById('gim-extra-btn');
|
||||
const modalContext = 'generic-input-modal';
|
||||
|
||||
document.getElementById('gim-title').innerText = title;
|
||||
document.getElementById('gim-label').innerText = label;
|
||||
inp.value = val || '';
|
||||
|
||||
let resultToResolve = null;
|
||||
|
||||
// 1. Cleanup & Resolve
|
||||
const close = () => {
|
||||
window.removeEventListener('popstate', onPopState);
|
||||
modal.style.display = 'none';
|
||||
resolve(resultToResolve);
|
||||
};
|
||||
|
||||
// 2. Handle History Changes (Back Button)
|
||||
const onPopState = (e) => {
|
||||
// If we are here, history has ALREADY popped.
|
||||
// Just close the UI.
|
||||
close();
|
||||
};
|
||||
|
||||
// 3. Handle UI Actions (OK / Cancel)
|
||||
const commit = (v) => {
|
||||
resultToResolve = v;
|
||||
|
||||
// SAFETY CHECK: Only go back if we are still in the modal state.
|
||||
// This prevents "Double Back" if the user mashed buttons or browser lagged.
|
||||
if (history.state && history.state.context === modalContext) {
|
||||
history.back(); // This will trigger onPopState -> close()
|
||||
} else {
|
||||
// We are already out of state (shouldn't happen, but safe fallback)
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Setup DOM
|
||||
const newOk = document.getElementById('gim-ok-btn').cloneNode(true);
|
||||
const newCancel = document.getElementById('gim-cancel-btn').cloneNode(true);
|
||||
const newExtra = extraBtn.cloneNode(true);
|
||||
|
||||
document.getElementById('gim-ok-btn').replaceWith(newOk);
|
||||
document.getElementById('gim-cancel-btn').replaceWith(newCancel);
|
||||
extraBtn.replaceWith(newExtra);
|
||||
|
||||
// StopPropagation prevents clicks from bubbling to Inspector canvas (Ghost clicks)
|
||||
newOk.onclick = (e) => { e.stopPropagation(); commit(inp.value.trim()); };
|
||||
newCancel.onclick = (e) => { e.stopPropagation(); commit(null); };
|
||||
|
||||
if(opts.extraBtn) {
|
||||
newExtra.style.display = 'block';
|
||||
newExtra.innerText = opts.extraBtn.label;
|
||||
newExtra.className = opts.extraBtn.class || 'danger';
|
||||
newExtra.onclick = (e) => { e.stopPropagation(); commit(opts.extraBtn.value); };
|
||||
} else {
|
||||
newExtra.style.display = 'none';
|
||||
}
|
||||
|
||||
modal.querySelector('.close-btn').onclick = (e) => { e.stopPropagation(); commit(null); };
|
||||
|
||||
inp.onkeydown = (e) => {
|
||||
if(e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
newOk.click();
|
||||
}
|
||||
// Let NavManager handle Escape -> history.back()
|
||||
};
|
||||
|
||||
// 5. Open & Push State
|
||||
// Use direct history.pushState to ensure it happens immediately and locally
|
||||
// (Bypassing any potential NavManager checks/delays)
|
||||
if (!history.state || history.state.context !== modalContext) {
|
||||
history.pushState({ context: modalContext }, "", "");
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', onPopState);
|
||||
modal.style.display = 'flex';
|
||||
inp.focus();
|
||||
inp.select();
|
||||
});
|
||||
}
|
||||
|
||||
// Start
|
||||
window.onload = init;
|
||||
|
||||
window.exportKiCad = () => {
|
||||
if (window.netManager) window.netManager.exportKiCad();
|
||||
if (window.netManager) window.netManager.exportKiCad();
|
||||
};
|
||||
|
||||
window.startNewNet = () => {
|
||||
if (window.inspector) window.inspector.startNewNet();
|
||||
if (window.inspector) window.inspector.startNewNet();
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue