Fixed manual image stitching and Inspector view for mobiles, other minor fixes/improvements.

This commit is contained in:
Taras Greben 2025-12-21 18:45:40 +02:00
commit ca9fe52db5
10 changed files with 803 additions and 618 deletions

View file

@ -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) {
@ -7,9 +7,8 @@ class PanZoomCanvas {
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.t = {x:0, y:0, k:1};
this.drag = false;
this.lm = {x:0, y:0};
this.activePtIdx = -1;
this.isMirrored = false;
@ -18,6 +17,12 @@ class PanZoomCanvas {
this.onClick = onClick;
this.onDragPt = onDragPt;
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);
this.zoomAt(e.clientX, e.clientY, f);
}, { passive: false });
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.addEventListener('contextmenu', e => {
const coords = this.getImgCoords(e.clientX, e.clientY);
if (this.onDragPt) this.onDragPt(coords.x, coords.y, 'delete');
});
}
zoomAt(clientX, clientY, factor) {
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));
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);
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.isDragging = true;
this.lm = { x: e.clientX, y: e.clientY };
return;
}
}
this.drag = true;
this.isDragging = true;
this.lm = { x: e.clientX, y: e.clientY };
this.activePtIdx = -1;
};
}
this.canvas.oncontextmenu = e => {
e.preventDefault();
const coords = this.getImgCoords(e.clientX, e.clientY);
if (this.onDragPt) this.onDragPt(coords.x, coords.y, 'delete');
};
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);
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 };
this.onDragPt(curr.x - prev.x, curr.y - prev.y, 'move', this.activePtIdx);
} 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.t.x += dx;
this.t.y += dy;
this.draw();
}
this.lm = { x: e.clientX, y: e.clientY };
}
if(this.onMouseMove && e.target === this.canvas) {
if(this.onMouseMove) {
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;
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);
}
}
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) {

View file

@ -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;
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;
@ -114,76 +130,49 @@ 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;
}
);
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();
cvs.addEventListener('contextmenu', (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();
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);
}
}
}
});
this.viewers[id] = viewer;
@ -191,12 +180,18 @@ class Inspector {
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;
}
// --- 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,37 +201,37 @@ class Inspector {
this.updateNetNodeCache();
}
handleNodeClick(imgId, x, y) {
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 = this.netNodeCache[imgId].find(n => {
const dist = Math.hypot(n.x - x, n.y - y);
return (dist * viewer.t.k) < HIT_RADIUS;
});
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);
// 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 (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
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 (res) {
this.updateNetUI();
Object.values(this.viewers).forEach(v => v.draw());
}
return true; // Handled
return true;
}
return false; // Not handled
return false;
}
toggleLayer(id, isVisible) {
@ -464,24 +459,16 @@ 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);
const label = await requestInput("Add Node", "Pad/Pin Name", defaultLabel);
if(label) {
if(!this.activeNet) {
this.startNewNet();
}
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());
@ -493,25 +480,19 @@ class Inspector {
// Only prompt for name if it's a new net
if (this.activeNet.isNew) {
const name = prompt("Net Name:", this.activeNet.name);
const name = await requestInput("Save Net", "Net Name", this.activeNet.name);
if(name) {
this.activeNet.name = name;
delete this.activeNet.isNew; // Remove flag before saving
delete this.activeNet.isNew;
} else {
return; // Cancelled by user in prompt
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() {

View file

@ -5,13 +5,23 @@ class NetManager {
this.db = db;
}
async render() {
async render() {
const tbody = document.getElementById('nets-body');
if(!tbody) return;
// Safety check: If no board is loaded, clear the table
if (typeof currentBomId === 'undefined' || !currentBomId) {
tbody.innerHTML = '';
return;
}
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center; color:#888;">Loading...</td></tr>';
const nets = await this.db.getNets();
const allNets = await this.db.getNets();
// --- FIX: Filter by Current Board ID ---
const nets = allNets.filter(n => n.projectId === currentBomId);
tbody.innerHTML = '';
if(nets.length === 0) {
@ -39,7 +49,6 @@ class NetManager {
<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>
@ -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
const res = await requestInput("Edit Node", "Node Name", node.label, {
extraBtn: { label: 'Delete', value: '__DELETE__', class: 'danger' }
});
if(newName.trim() === '') {
if(confirm("Delete this node?")) {
if (res === '__DELETE__') {
net.nodes.splice(nodeIdx, 1);
await this.db.addNet(net);
this.render();
}
} else {
net.nodes[nodeIdx].label = newName.trim();
} else if (res) {
net.nodes[nodeIdx].label = res;
await this.db.addNet(net);
this.render();
}

View file

@ -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,16 +9,21 @@ 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();
}
@ -148,9 +149,19 @@ 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);
@ -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();
}
}

View file

@ -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; }
@ -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()">

View file

@ -106,6 +106,10 @@ 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)
@ -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(/\.[^/.]+$/, "");
@ -246,17 +273,23 @@ async function loadProjectData() {
});
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();
}
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;
@ -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
@ -2337,6 +2371,101 @@ 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;