2026-05-23 09:09:46 +00:00
/ *
* Copyright ( c ) 2025 - 2026 Taras Greben
* SPDX - License - Identifier : AGPL - 3.0 - only OR LicenseRef - Commercial - pcb - retrace
* See LICENSE file for details .
* /
2025-12-21 00:31:26 +02:00
/* studio.js - Main Application Logic */
const ICONS = { RESISTOR : ` <svg viewBox="0 0 24 24" fill="none"><rect x="4" y="6" width="16" height="12" rx="3" fill="#bae6fd" stroke="#0ea5e9" stroke-width="1"/><rect x="7" y="6" width="2" height="12" fill="#ef4444"/><rect x="11" y="6" width="2" height="12" fill="#000000"/><rect x="15" y="6" width="2" height="12" fill="#ef4444"/><line x1="1" y1="12" x2="4" y2="12" stroke="#94a3b8" stroke-width="2"/><line x1="20" y1="12" x2="23" y2="12" stroke="#94a3b8" stroke-width="2"/></svg> ` , INDUCTOR : ` <svg viewBox="0 0 24 24" fill="none"><rect x="4" y="5" width="16" height="14" rx="4" fill="#bbf7d0" stroke="#22c55e" stroke-width="1"/><rect x="7" y="5" width="2" height="14" fill="#cbd5e1"/><rect x="11" y="5" width="2" height="14" fill="#ef4444"/><rect x="15" y="5" width="2" height="14" fill="#ef4444"/><line x1="1" y1="12" x2="4" y2="12" stroke="#94a3b8" stroke-width="2"/><line x1="20" y1="12" x2="23" y2="12" stroke="#94a3b8" stroke-width="2"/></svg> ` , COIL : ` <svg viewBox="0 0 24 24" fill="none" stroke="#ea580c" stroke-width="2" stroke-linecap="round"><line x1="1" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="23" y2="12"/><path d="M5 12 C5 4 9 4 9 12"/><path d="M9 12 C9 19 10 19 10 12" stroke-opacity="0.5"/><path d="M10 12 C10 4 14 4 14 12"/><path d="M14 12 C14 19 15 19 15 12" stroke-opacity="0.5"/><path d="M15 12 C15 4 19 4 19 12"/></svg> ` } ;
2026-04-24 17:28:59 +03:00
const TOOL _REGISTRY = { 'R' : [ { url : 'resistor.html' , icon : ICONS . RESISTOR , title : 'Resistor' } ] , 'L' : [ { url : 'inductor.html' , icon : ICONS . INDUCTOR , title : 'Inductor' } , { url : 'coil.html' , icon : ICONS . COIL , title : 'Coil' } ] } ;
2025-12-21 00:31:26 +02:00
const MAX _DIRECT _TOOLS = 3 ;
2025-12-26 14:55:08 +02:00
const PIN _HELP _HTML = `
< strong style = "display:block; margin-bottom:4px;" > Pin # Conventions : < / s t r o n g >
< ul style = "margin:0; padding-left:1.2rem; display:flex; flex-direction:column; gap:2px;" >
< li > < b > Res / Cap / Ind : < / b > L e f t / T o p = 1 < / l i >
< li > < b > Diode : < / b > S t r i p e ( K ) = 1 < / l i >
< li > < b > LED : < / b > C a t h o d e ( K ) = 1 < / l i >
< li > < b > Tantalum : < / b > S t r i p e ( + ) = 1 ⚠ ️ < / l i >
< li > < b > Electrolytic : < / b > S t r i p e ( - ) = 2 ⚠ ️ < / l i >
< li > < b > IC : < / b > D o t / N o t c h = 1 ( C C W ) < / l i >
< / u l >
` ;
2025-12-21 00:31:26 +02:00
// Global State
2026-04-24 17:28:59 +03:00
let db = null , deviceList = [ ] , currentDeviceId = null , bomList = [ ] , currentBomId = null , bomData = [ ] , bomImages = [ ] , currentImgId = null , sortMode = 'none' , editingIndex = - 1 , mapState = { scale : 1 , x : 0 , y : 0 , isDragging : false , startX : 0 , startY : 0 } ;
2025-12-21 00:31:26 +02:00
let skipNextFit = false ;
let returnToMap = false ;
let spyglass = null ;
let isMainViewActive = true ; // Tracks if Spyglass is showing the actual source image
// Phase 6 Extensions
let cvManager = null ;
let stitchEditor = null ;
let currentOverlaps = [ ] ;
let netManager = null ;
let inspector = null ;
class PcbDatabase {
2026-04-24 17:28:59 +03:00
constructor ( ) { this . name = window . PcbDbCore . DB _NAME ; this . ver = window . PcbDbCore . DB _VER ; }
async init ( ) {
return new Promise ( ( r , j ) => {
const q = indexedDB . open ( this . name , this . ver ) ;
q . onupgradeneeded = e => {
window . PcbDbCore . setupDatabase ( e . target . result , e . target . transaction ) ;
} ;
q . onsuccess = e => { this . db = e . target . result ; r ( ) } ;
q . onerror = e => { console . error ( "DB Open Error:" , e ) ; j ( e ) ; } ;
} ) ;
}
2025-12-21 00:31:26 +02:00
// Helpers
2026-04-24 17:28:59 +03:00
async _tx ( s , m , cb ) { return new Promise ( ( r , j ) => { const t = this . db . transaction ( s , m ) ; const q = cb ( t . objectStore ( s ) ) ; q . onsuccess = ( ) => r ( q . result ) ; q . onerror = ( ) => j ( q . error ) ; } ) ; }
async _ix ( s , i , v ) { return new Promise ( ( r , j ) => { const q = this . db . transaction ( s , 'readonly' ) . objectStore ( s ) . index ( i ) . getAll ( v ) ; q . onsuccess = ( ) => r ( q . result ) ; q . onerror = ( ) => j ( q . error ) ; } ) ; }
2025-12-21 00:31:26 +02:00
// Core CRUD
2026-04-24 17:28:59 +03:00
async getDevices ( ) { return this . _tx ( 'devices' , 'readonly' , s => s . getAll ( ) ) ; }
async addDevice ( d ) { return this . _tx ( 'devices' , 'readwrite' , s => s . put ( d ) ) ; }
2025-12-21 00:31:26 +02:00
async getProjectsByDevice ( devId ) { return this . _ix ( 'boards' , 'deviceId' , devId ) ; }
2026-04-24 17:28:59 +03:00
async getProject ( id ) { return this . _tx ( 'boards' , 'readonly' , s => s . get ( id ) ) ; }
async addProject ( p ) { return this . _tx ( 'boards' , 'readwrite' , s => s . put ( p ) ) ; }
async deleteProject ( id ) { const cs = await this . getComponents ( id ) ; const is = await this . getImages ( id ) ; const tx = this . db . transaction ( [ 'boards' , 'components' , 'images' ] , 'readwrite' ) ; cs . forEach ( c => tx . objectStore ( 'components' ) . delete ( c . id ) ) ; is . forEach ( i => tx . objectStore ( 'images' ) . delete ( i . id ) ) ; tx . objectStore ( 'boards' ) . delete ( id ) ; return new Promise ( r => tx . oncomplete = r ) ; }
async getComponents ( pid ) { return this . _ix ( 'components' , 'boardId' , pid ) ; }
async addComponent ( c ) { return this . _tx ( 'components' , 'readwrite' , s => s . put ( c ) ) ; }
async deleteComponent ( id ) { return this . _tx ( 'components' , 'readwrite' , s => s . delete ( id ) ) ; }
async clearComponents ( pid ) { const cs = await this . getComponents ( pid ) ; const tx = this . db . transaction ( 'components' , 'readwrite' ) ; cs . forEach ( c => tx . objectStore ( 'components' ) . delete ( c . id ) ) ; return new Promise ( r => tx . oncomplete = r ) ; }
async clearProjectData ( pid ) { const cs = await this . getComponents ( pid ) ; const is = await this . getImages ( pid ) ; const tx = this . db . transaction ( [ 'components' , 'images' ] , 'readwrite' ) ; cs . forEach ( c => tx . objectStore ( 'components' ) . delete ( c . id ) ) ; is . forEach ( i => tx . objectStore ( 'images' ) . delete ( i . id ) ) ; return new Promise ( r => tx . oncomplete = r ) ; }
async getImages ( pid ) { return this . _ix ( 'images' , 'boardId' , pid ) ; }
async addImage ( i ) { return this . _tx ( 'images' , 'readwrite' , s => s . put ( i ) ) ; }
async deleteImage ( id ) { return this . _tx ( 'images' , 'readwrite' , s => s . delete ( id ) ) ; }
2025-12-21 00:31:26 +02:00
async getImage ( id ) { return this . _tx ( 'images' , 'readonly' , s => s . get ( id ) ) ; }
2026-04-24 17:28:59 +03:00
async getNets ( ) { return this . _tx ( 'nets' , 'readonly' , s => s . getAll ( ) ) ; }
async addNet ( n ) { return this . _tx ( 'nets' , 'readwrite' , s => s . put ( n ) ) ; }
async deleteNet ( id ) { return this . _tx ( 'nets' , 'readwrite' , s => s . delete ( id ) ) ; }
2025-12-21 00:31:26 +02:00
// POI Extensions
2026-04-24 17:28:59 +03:00
async addOverlap ( ov ) { return this . _tx ( 'overlappedImages' , 'readwrite' , s => s . put ( ov ) ) ; }
2025-12-21 00:31:26 +02:00
async getOverlapsForPair ( id1 , id2 ) {
2026-04-24 17:28:59 +03:00
const all = await this . _tx ( 'overlappedImages' , 'readonly' , s => s . getAll ( ) ) ;
return all . find ( x => ( x . fromImageId === id1 && x . toImageId === id2 ) || ( x . fromImageId === id2 && x . toImageId === id1 ) ) ;
2025-12-21 00:31:26 +02:00
}
async getOverlapsForImage ( id ) {
2026-04-24 17:28:59 +03:00
const all = await this . _tx ( 'overlappedImages' , 'readonly' , s => s . getAll ( ) ) ;
return all . filter ( x => x . fromImageId === id || x . toImageId === id ) ;
2025-12-21 00:31:26 +02:00
}
async deleteOverlapsForPair ( id1 , id2 ) {
const tx = this . db . transaction ( 'overlappedImages' , 'readwrite' ) ;
const store = tx . objectStore ( 'overlappedImages' ) ;
const req = store . getAll ( ) ;
return new Promise ( resolve => {
req . onsuccess = e => {
const all = e . target . result ;
2026-04-24 17:28:59 +03:00
const toDel = all . filter ( x => ( x . fromImageId === id1 && x . toImageId === id2 ) || ( x . fromImageId === id2 && x . toImageId === id1 ) ) ;
let c = 0 ;
if ( toDel . length === 0 ) resolve ( ) ;
toDel . forEach ( item => { store . delete ( item . id ) . onsuccess = ( ) => { c ++ ; if ( c === toDel . length ) resolve ( ) ; } } ) ;
2025-12-21 00:31:26 +02:00
}
} ) ;
}
2026-04-24 17:28:59 +03:00
async getSchemas ( ) { return this . _tx ( 'schemas' , 'readonly' , s => s . getAll ( ) ) . catch ( ( ) => [ ] ) ; }
async addSchema ( s ) { return this . _tx ( 'schemas' , 'readwrite' , st => st . put ( s ) ) ; }
async deleteSchema ( id ) { return this . _tx ( 'schemas' , 'readwrite' , st => st . delete ( id ) ) ; }
async getSchemaComponents ( ) { return this . _tx ( 'schemaComponents' , 'readonly' , s => s . getAll ( ) ) . catch ( ( ) => [ ] ) ; }
async addSchemaComponent ( sc ) { return this . _tx ( 'schemaComponents' , 'readwrite' , s => s . put ( sc ) ) ; }
async deleteSchemaComponent ( id ) { return this . _tx ( 'schemaComponents' , 'readwrite' , s => s . delete ( id ) ) ; }
async getKicadSymbols ( ) { return this . _tx ( 'kicadSymbols' , 'readonly' , s => s . getAll ( ) ) . catch ( ( ) => [ ] ) ; }
async addKicadSymbol ( s ) { return this . _tx ( 'kicadSymbols' , 'readwrite' , st => st . put ( s ) ) ; }
async getComponentTypes ( ) { return this . _tx ( 'componentTypes' , 'readonly' , s => s . getAll ( ) ) . catch ( ( ) => [ ] ) ; }
async addComponentType ( ct ) { return this . _tx ( 'componentTypes' , 'readwrite' , s => s . put ( ct ) ) ; }
async getComponentTypeKicadSymbols ( ) { return this . _tx ( 'componentTypeKicadSymbols' , 'readonly' , s => s . getAll ( ) ) . catch ( ( ) => [ ] ) ; }
async addComponentTypeKicadSymbol ( ctks ) { return this . _tx ( 'componentTypeKicadSymbols' , 'readwrite' , s => s . put ( ctks ) ) ; }
async getComponentTypePins ( ) { return this . _tx ( 'componentTypePins' , 'readonly' , s => s . getAll ( ) ) . catch ( ( ) => [ ] ) ; }
async addComponentTypePin ( ctp ) { return this . _tx ( 'componentTypePins' , 'readwrite' , s => s . put ( ctp ) ) ; }
async getSymbolTypes ( ) { return this . _tx ( 'symbolTypes' , 'readonly' , s => s . getAll ( ) ) . catch ( ( ) => [ ] ) ; }
async addSymbolType ( st ) { return this . _tx ( 'symbolTypes' , 'readwrite' , s => s . put ( st ) ) ; }
2025-12-21 00:31:26 +02:00
}
// MAIN INIT
async function init ( ) {
db = new PcbDatabase ( ) ;
await db . init ( ) ;
2025-12-21 18:45:40 +02:00
if ( ! history . state ) {
history . replaceState ( { context : 'list' } , "" , "" ) ;
}
2025-12-21 00:31:26 +02:00
cvManager = new CVManager ( ) ;
cvManager . init ( ) ; // Lazy load (don't await)
2025-12-21 18:45:40 +02:00
// Init net managers
netManager = new NetManager ( db ) ;
inspector = new Inspector ( db , cvManager ) ;
2025-12-21 00:31:26 +02:00
2025-12-21 18:45:40 +02:00
// [CRITICAL] Expose to window so inline HTML onclicks (generated by Inspector) work
window . netManager = netManager ;
window . inspector = inspector ;
2025-12-21 00:31:26 +02:00
stitchEditor = new StitchEditor ( db , cvManager ) ;
deviceList = await db . getDevices ( ) ;
2026-04-24 17:28:59 +03:00
if ( deviceList . length === 0 ) {
2025-12-21 00:31:26 +02:00
const defDev = { id : uuid ( ) , name : 'Default Device' } ;
await db . addDevice ( defDev ) ;
deviceList = [ defDev ] ;
const bid = uuid ( ) ;
2026-04-24 17:28:59 +03:00
await db . addProject ( { id : bid , deviceId : defDev . id , name : 'Main Board' , sortMode : 'none' } ) ;
2025-12-21 00:31:26 +02:00
}
2026-04-24 17:28:59 +03:00
currentDeviceId = localStorage . getItem ( 'pcb_device_id' ) || deviceList [ 0 ] . id ;
2025-12-21 00:31:26 +02:00
if ( ! deviceList . find ( d => d . id === currentDeviceId ) ) currentDeviceId = deviceList [ 0 ] . id ;
updateDeviceDropdown ( ) ;
await loadDeviceBoms ( ) ;
2026-04-24 17:28:59 +03:00
document . getElementById ( 'add-form' ) . addEventListener ( 'keydown' , function ( e ) { if ( e . key === 'Enter' ) addPart ( ) ; } ) ;
2025-12-21 00:31:26 +02:00
if ( typeof PcbSpyglass !== 'undefined' ) {
spyglass = new PcbSpyglass ( 'preview-canvas' , 'zoom-level' , ( newX , newY ) => {
// When user drags the spyglass, update the hidden inputs (only for the Main View)
if ( isMainViewActive ) {
document . getElementById ( 'inp-x' ) . value = newX ;
document . getElementById ( 'inp-y' ) . value = newY ;
}
// Optional: If we want real-time DB saving while dragging in the list view,
// we could enable it, but usually updating inputs + clicking "Add/Update" is safer.
} ) ;
}
2025-12-22 16:56:46 +02:00
// Check for ?import={url} parameter
const urlParams = new URLSearchParams ( window . location . search ) ;
const importUrl = urlParams . get ( 'import' ) ;
if ( importUrl ) {
// 1. Clean URL immediately so refresh doesn't trigger loop
const cleanLocation = window . location . protocol + "//" + window . location . host + window . location . pathname ;
window . history . replaceState ( { context : 'list' } , '' , cleanLocation ) ;
// 2. Trigger import (small delay to ensure DB/UI is ready)
setTimeout ( ( ) => processUrlImport ( importUrl ) , 500 ) ;
}
2025-12-21 00:31:26 +02:00
setupDragDrop ( ) ;
NavManager . init ( ) ;
2026-04-24 17:28:59 +03:00
const startupTab = localStorage . getItem ( 'pcb_startup_tab' ) ;
if ( startupTab ) {
localStorage . removeItem ( 'pcb_startup_tab' ) ;
setTimeout ( ( ) => switchView ( startupTab ) , 100 ) ;
}
2025-12-21 00:31:26 +02:00
}
// Helper: Find all transitive connections for a given image
// Returns Array of { sourceId, matrix (Source->Target) }
async function getConnectedImages ( targetImgId ) {
if ( ! targetImgId || ! cvManager ) return [ ] ;
// Use Helper
const paths = await ImageGraph . solvePaths ( targetImgId , cvManager , db ) ;
// Map to expected format
return paths . map ( p => ( {
sourceId : p . id ,
matrix : p . H
} ) ) ;
}
function updateDeviceDropdown ( ) {
2026-04-24 17:28:59 +03:00
const s = document . getElementById ( 'device-select' ) ; s . innerHTML = '' ;
2025-12-21 00:31:26 +02:00
deviceList . forEach ( d => {
2026-04-24 17:28:59 +03:00
const o = document . createElement ( 'option' ) ; o . value = d . id ; o . innerText = d . name ;
if ( d . id === currentDeviceId ) o . selected = true ;
2025-12-21 00:31:26 +02:00
s . appendChild ( o ) ;
} ) ;
}
async function switchDevice ( ) {
resetStickyEditor ( ) ;
currentDeviceId = document . getElementById ( 'device-select' ) . value ;
2026-04-24 17:28:59 +03:00
localStorage . setItem ( 'pcb_device_id' , currentDeviceId ) ;
2025-12-21 00:31:26 +02:00
await loadDeviceBoms ( ) ;
}
async function loadDeviceBoms ( ) {
bomList = await db . getProjectsByDevice ( currentDeviceId ) ;
2026-04-24 17:28:59 +03:00
const s = document . getElementById ( 'bom-select' ) ; s . innerHTML = '' ;
2025-12-21 00:31:26 +02:00
if ( bomList . length === 0 ) {
s . innerHTML = '<option disabled selected>No Boards</option>' ;
currentBomId = null ;
} else {
const groups = { } ;
bomList . forEach ( b => {
const sec = b . section || "General" ;
2026-04-24 17:28:59 +03:00
if ( ! groups [ sec ] ) groups [ sec ] = [ ] ;
2025-12-21 00:31:26 +02:00
groups [ sec ] . push ( b ) ;
} ) ;
for ( const [ secName , boms ] of Object . entries ( groups ) ) {
const grp = document . createElement ( 'optgroup' ) ; grp . label = secName ;
boms . forEach ( b => {
2026-04-24 17:28:59 +03:00
const o = document . createElement ( 'option' ) ; o . value = b . id ; o . innerText = b . name ;
2025-12-21 00:31:26 +02:00
grp . appendChild ( o ) ;
} ) ;
s . appendChild ( grp ) ;
}
2026-04-24 17:28:59 +03:00
const lastBom = localStorage . getItem ( 'pcb_board_id' ) ;
currentBomId = ( lastBom && bomList . find ( b => b . id === lastBom ) ) ? lastBom : bomList [ 0 ] . id ;
2025-12-21 00:31:26 +02:00
s . value = currentBomId ;
}
await loadProjectData ( ) ;
}
async function switchBom ( ) {
resetStickyEditor ( ) ;
currentBomId = document . getElementById ( 'bom-select' ) . value ;
2026-04-24 17:28:59 +03:00
localStorage . setItem ( 'pcb_board_id' , currentBomId ) ;
2025-12-21 00:31:26 +02:00
currentImgId = null ;
await loadProjectData ( ) ;
}
async function loadProjectData ( ) {
2025-12-21 18:45:40 +02:00
// 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
}
2026-04-24 17:28:59 +03:00
const schemaView = document . getElementById ( 'view-schema' ) ;
if ( schemaView && schemaView . classList . contains ( 'active' ) ) {
switchView ( 'schema' ) ;
}
2025-12-21 18:45:40 +02:00
} ;
if ( ! currentBomId ) {
bomData = [ ] ;
bomImages = [ ] ;
renderList ( ) ;
// FIX: Clear other views if no board selected
const imgSel = document . getElementById ( 'image-select' ) ;
2026-04-24 17:28:59 +03:00
if ( imgSel ) imgSel . innerHTML = '<option disabled selected>No Images</option>' ;
2025-12-21 18:45:40 +02:00
clearMap ( ) ;
await refreshViews ( ) ;
return ;
}
2025-12-21 00:31:26 +02:00
2026-04-24 17:28:59 +03:00
const meta = bomList . find ( p => p . id === currentBomId ) ;
2025-12-21 00:31:26 +02:00
if ( meta ) {
sortMode = ( meta && meta . sortMode ) ? meta . sortMode : 'none' ;
document . getElementById ( 'sort-select' ) . value = sortMode ;
}
bomData = await db . getComponents ( currentBomId ) ;
bomImages = await db . getImages ( currentBomId ) ;
2025-12-21 18:45:40 +02:00
// Convert raw blobs to Image objects for cache if needed (lazy loaded usually)
2025-12-21 00:31:26 +02:00
for ( const img of bomImages ) {
if ( /\.(jpg|jpeg|png|webp)$/i . test ( img . name ) ) {
img . name = img . name . replace ( /\.[^/.]+$/ , "" ) ;
db . addImage ( img ) ;
}
}
const imgSel = document . getElementById ( 'image-select' ) ;
imgSel . innerHTML = '' ;
2026-04-24 17:28:59 +03:00
if ( bomImages . length > 0 ) {
2025-12-21 18:45:40 +02:00
bomImages . forEach ( img => {
const opt = document . createElement ( 'option' ) ;
opt . value = img . id ; opt . innerText = img . name ;
imgSel . appendChild ( opt ) ;
} ) ;
2026-04-24 17:28:59 +03:00
if ( ! currentImgId || ! bomImages . find ( i => i . id === currentImgId ) ) currentImgId = bomImages [ 0 ] . id ;
2025-12-21 18:45:40 +02:00
imgSel . value = currentImgId ;
// Only load the image if we are actually on the map tab
2026-04-24 17:28:59 +03:00
if ( document . getElementById ( 'view-map' ) . classList . contains ( 'active' ) ) showImage ( currentImgId ) ;
2025-12-21 00:31:26 +02:00
} else {
2025-12-21 18:45:40 +02:00
currentImgId = null ;
imgSel . innerHTML = '<option disabled selected>No Images</option>' ;
clearMap ( ) ;
2025-12-21 00:31:26 +02:00
}
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
renderList ( ) ;
2025-12-21 18:45:40 +02:00
// Refresh Nets and Inspector with new data
await refreshViews ( ) ;
2025-12-21 00:31:26 +02:00
}
async function createNewDevice ( ) {
2025-12-21 18:45:40 +02:00
const n = await requestInput ( "New Device" , "Device Name" , "" ) ;
2025-12-21 00:31:26 +02:00
if ( n ) {
const id = uuid ( ) ;
await db . addDevice ( { id , name : n } ) ;
currentDeviceId = id ;
const bid = uuid ( ) ;
await db . addProject ( { id : bid , deviceId : id , name : 'Main Board' , sortMode : 'none' } ) ;
deviceList = await db . getDevices ( ) ;
updateDeviceDropdown ( ) ;
await loadDeviceBoms ( ) ;
}
}
async function createNewBom ( ) {
if ( ! currentDeviceId ) return alert ( "Select a device first." ) ;
2025-12-21 18:45:40 +02:00
const n = await requestInput ( "New Board" , "Board Name" , "" ) ;
2026-04-24 17:28:59 +03:00
if ( n ) {
2025-12-21 18:45:40 +02:00
const sec = await requestInput ( "Section" , "Group (Optional)" , "" ) || "" ;
2026-04-24 17:28:59 +03:00
const id = uuid ( ) ;
await db . addProject ( { id , deviceId : currentDeviceId , name : n , section : sec , lastModified : Date . now ( ) , sortMode : 'none' } ) ;
currentBomId = id ;
2025-12-21 00:31:26 +02:00
await loadDeviceBoms ( ) ;
}
}
// --- SETTINGS ---
function openBoardSettings ( ) {
if ( ! currentBomId ) return ;
const p = bomList . find ( x => x . id === currentBomId ) ;
if ( ! p ) return ;
document . getElementById ( 'edit-board-name' ) . value = p . name ;
document . getElementById ( 'edit-board-section' ) . value = p . section || "" ;
const ds = document . getElementById ( 'edit-board-device' ) ; ds . innerHTML = '' ;
deviceList . forEach ( d => {
const o = document . createElement ( 'option' ) ; o . value = d . id ; o . innerText = d . name ;
if ( d . id === p . deviceId ) o . selected = true ;
ds . appendChild ( o ) ;
} ) ;
document . getElementById ( 'board-settings-modal' ) . style . display = 'flex' ;
}
async function saveBoardSettings ( ) {
const p = bomList . find ( x => x . id === currentBomId ) ;
if ( ! p ) return ;
const newName = document . getElementById ( 'edit-board-name' ) . value ;
const newSec = document . getElementById ( 'edit-board-section' ) . value ;
const newDevId = document . getElementById ( 'edit-board-device' ) . value ;
if ( newName ) {
p . name = newName ; p . section = newSec ; p . deviceId = newDevId ;
await db . addProject ( p ) ;
2025-12-22 18:45:13 +02:00
history . back ( ) ;
2025-12-21 00:31:26 +02:00
if ( newDevId !== currentDeviceId ) {
currentDeviceId = newDevId ;
updateDeviceDropdown ( ) ;
}
await loadDeviceBoms ( ) ;
}
}
function openDeviceSettings ( ) {
if ( ! currentDeviceId ) return ;
const d = deviceList . find ( x => x . id === currentDeviceId ) ;
if ( ! d ) return ;
document . getElementById ( 'edit-device-name' ) . value = d . name ;
document . getElementById ( 'device-settings-modal' ) . style . display = 'flex' ;
}
async function saveDeviceSettings ( ) {
const d = deviceList . find ( x => x . id === currentDeviceId ) ;
if ( ! d ) return ;
const newName = document . getElementById ( 'edit-device-name' ) . value ;
if ( newName && newName !== d . name ) {
d . name = newName ;
await db . addDevice ( d ) ;
deviceList = await db . getDevices ( ) ;
updateDeviceDropdown ( ) ;
}
2025-12-22 18:45:13 +02:00
history . back ( ) ;
2025-12-21 00:31:26 +02:00
}
async function deleteCurrentDevice ( ) {
if ( ! currentDeviceId ) return ;
// 1. Modal Check
if ( ! await confirmAction ( "Are you sure? This will PERMANENTLY DELETE the device and ALL its boards, components, and images.\n\nThis action cannot be undone." , "Delete Device" ) ) {
return ;
}
try {
const boards = await db . getProjectsByDevice ( currentDeviceId ) ;
for ( const board of boards ) {
const images = await db . getImages ( board . id ) ;
for ( const img of images ) {
const overlaps = await db . getOverlapsForImage ( img . id ) ;
const tx = db . db . transaction ( 'overlappedImages' , 'readwrite' ) ;
const store = tx . objectStore ( 'overlappedImages' ) ;
overlaps . forEach ( ov => store . delete ( ov . id ) ) ;
await new Promise ( r => tx . oncomplete = r ) ;
}
await db . deleteProject ( board . id ) ;
}
await db . _tx ( 'devices' , 'readwrite' , s => s . delete ( currentDeviceId ) ) ;
2025-12-22 18:45:13 +02:00
history . back ( ) ;
2025-12-21 00:31:26 +02:00
deviceList = await db . getDevices ( ) ;
if ( deviceList . length === 0 ) {
const defDev = { id : uuid ( ) , name : 'Default Device' } ;
await db . addDevice ( defDev ) ;
deviceList = [ defDev ] ;
const bid = uuid ( ) ;
2026-04-24 17:28:59 +03:00
await db . addProject ( { id : bid , deviceId : defDev . id , name : 'Main Board' , sortMode : 'none' } ) ;
2025-12-21 00:31:26 +02:00
}
currentDeviceId = deviceList [ 0 ] . id ;
2026-04-24 17:28:59 +03:00
localStorage . setItem ( 'pcb_device_id' , currentDeviceId ) ;
2025-12-21 00:31:26 +02:00
updateDeviceDropdown ( ) ;
await loadDeviceBoms ( ) ;
resetStickyEditor ( ) ;
} catch ( e ) {
console . error ( "Delete failed:" , e ) ;
alert ( "Error deleting device: " + e . message ) ;
}
}
// --- COMPONENTS ---
async function addPart ( ) {
const id = document . getElementById ( 'inp-id' ) . value ;
const label = document . getElementById ( 'inp-label' ) . value . toUpperCase ( ) . trim ( ) ;
const value = document . getElementById ( 'inp-value' ) . value ;
const desc = document . getElementById ( 'inp-desc' ) . value ;
const x = document . getElementById ( 'inp-x' ) . value ;
const y = document . getElementById ( 'inp-y' ) . value ;
const imgId = document . getElementById ( 'inp-img-id' ) . value ;
2026-04-24 17:28:59 +03:00
if ( ! label ) return alert ( "Ref required" ) ;
2025-12-21 00:31:26 +02:00
// Check collision
const collision = bomData . find ( c => c . label === label && c . id !== id ) ;
if ( collision ) {
2026-04-24 17:28:59 +03:00
if ( ! await confirmAction ( "Reference exists. Overwrite?" , "Overwrite" ) ) return ;
2025-12-21 00:31:26 +02:00
await db . deleteComponent ( collision . id ) ;
}
// Save
const c = { id : id || uuid ( ) , boardId : currentBomId , label , value , desc } ;
2026-04-24 17:28:59 +03:00
if ( x && y && imgId ) { c . x = parseFloat ( x ) ; c . y = parseFloat ( y ) ; c . imgId = imgId ; }
2025-12-21 00:31:26 +02:00
await db . addComponent ( c ) ;
document . getElementById ( 'inp-id' ) . value = c . id ;
await loadProjectData ( ) ;
2026-04-24 17:28:59 +03:00
if ( returnToMap ) { switchView ( 'map' ) ; returnToMap = false ; }
2025-12-21 00:31:26 +02:00
else {
// Optional: blink the button or give feedback
const btn = document . querySelector ( '.btn-add' ) ;
const origText = btn . innerText ;
btn . innerText = "Saved!" ;
setTimeout ( ( ) => btn . innerText = origText , 1000 ) ;
}
}
async function deleteCurrentPart ( ) {
const id = document . getElementById ( 'inp-id' ) . value ;
// Safety: Don't do anything if no component is selected (e.g. creating new)
if ( ! id ) return ;
if ( await confirmAction ( "Delete this component?" , "Delete" ) ) {
await db . deleteComponent ( id ) ;
await loadProjectData ( ) ;
resetStickyEditor ( ) ; // Clear the form/images after deletion
}
}
// --- IMPORT / EXPORT ---
2026-04-24 17:28:59 +03:00
// --- SCHEMA DEPENDENCY RESOLVERS ---
async function gatherSchemaDependencies ( schemaComps ) {
const usedCTIds = new Set ( schemaComps . map ( sc => sc . componentTypeId ) . filter ( Boolean ) ) ;
const componentTypes = ( await db . getComponentTypes ( ) || [ ] ) . filter ( ct => usedCTIds . has ( ct . id ) ) ;
const usedSymbolTypeIds = new Set ( componentTypes . map ( ct => ct . symbolTypeId ) . filter ( Boolean ) ) ;
const allSymbolTypes = await db . getSymbolTypes ( ) || [ ] ;
const symbolTypes = [ ] ;
const addSymType = ( id ) => {
if ( ! id ) return ;
const st = allSymbolTypes . find ( s => s . id === id ) ;
if ( st && ! symbolTypes . some ( s => s . id === id ) ) {
symbolTypes . push ( st ) ;
addSymType ( st . parentId ) ;
}
} ;
usedSymbolTypeIds . forEach ( addSymType ) ;
const componentTypePins = ( await db . getComponentTypePins ( ) || [ ] ) . filter ( p => usedCTIds . has ( p . componentTypeId ) ) ;
const componentTypeKicadSymbols = ( await db . getComponentTypeKicadSymbols ( ) || [ ] ) . filter ( ck => usedCTIds . has ( ck . componentTypeId ) ) ;
const usedKSIds = new Set ( componentTypeKicadSymbols . map ( ck => ck . kicadSymbolId ) . filter ( Boolean ) ) ;
const kicadSymbols = ( await db . getKicadSymbols ( ) || [ ] ) . filter ( ks => usedKSIds . has ( ks . id ) ) ;
return { componentTypes , symbolTypes , componentTypePins , componentTypeKicadSymbols , kicadSymbols } ;
}
async function importSchemaDependencies ( schemaDeps , idMap ) {
if ( ! schemaDeps ) return ;
const existingSTs = await db . getSymbolTypes ( ) || [ ] ;
for ( const st of schemaDeps . symbolTypes || [ ] ) {
const existing = existingSTs . find ( e => e . name === st . name ) ;
if ( existing ) { idMap . set ( st . id , existing . id ) ; } else {
const newSt = { ... st , id : uuid ( ) } ;
idMap . set ( st . id , newSt . id ) ;
await db . addSymbolType ( newSt ) ;
existingSTs . push ( newSt ) ;
}
}
for ( const st of schemaDeps . symbolTypes || [ ] ) {
if ( idMap . has ( st . id ) && idMap . get ( st . id ) !== st . id && st . parentId && idMap . has ( st . parentId ) ) {
const actualSt = existingSTs . find ( e => e . id === idMap . get ( st . id ) ) ;
if ( actualSt && actualSt . parentId !== idMap . get ( st . parentId ) ) {
actualSt . parentId = idMap . get ( st . parentId ) ;
await db . addSymbolType ( actualSt ) ;
}
}
}
const existingKSs = await db . getKicadSymbols ( ) || [ ] ;
for ( const ks of schemaDeps . kicadSymbols || [ ] ) {
const existing = existingKSs . find ( e => e . library === ks . library && e . symbol === ks . symbol ) ;
if ( existing ) { idMap . set ( ks . id , existing . id ) ; } else {
const newKs = { ... ks , id : uuid ( ) } ;
idMap . set ( ks . id , newKs . id ) ;
await db . addKicadSymbol ( newKs ) ;
existingKSs . push ( newKs ) ;
}
}
const existingCTs = await db . getComponentTypes ( ) || [ ] ;
for ( const ct of schemaDeps . componentTypes || [ ] ) {
const existing = existingCTs . find ( e => e . name === ct . name ) ;
if ( existing ) { idMap . set ( ct . id , existing . id ) ; } else {
const newCt = { ... ct , id : uuid ( ) , symbolTypeId : idMap . get ( ct . symbolTypeId ) || ct . symbolTypeId } ;
idMap . set ( ct . id , newCt . id ) ;
await db . addComponentType ( newCt ) ;
existingCTs . push ( newCt ) ;
}
}
const existingCTPs = await db . getComponentTypePins ( ) || [ ] ;
for ( const ctp of schemaDeps . componentTypePins || [ ] ) {
const mappedCtId = idMap . get ( ctp . componentTypeId ) || ctp . componentTypeId ;
const existing = existingCTPs . find ( e => e . componentTypeId === mappedCtId && e . name === ctp . name ) ;
if ( existing ) { idMap . set ( ctp . id , existing . id ) ; } else {
const newCtp = { ... ctp , id : uuid ( ) , componentTypeId : mappedCtId } ;
idMap . set ( ctp . id , newCtp . id ) ;
await db . addComponentTypePin ( newCtp ) ;
existingCTPs . push ( newCtp ) ;
}
}
const existingCTKSs = await db . getComponentTypeKicadSymbols ( ) || [ ] ;
for ( const ctks of schemaDeps . componentTypeKicadSymbols || [ ] ) {
const mappedCtId = idMap . get ( ctks . componentTypeId ) || ctks . componentTypeId ;
const mappedKsId = idMap . get ( ctks . kicadSymbolId ) || ctks . kicadSymbolId ;
const existing = existingCTKSs . find ( e => e . componentTypeId === mappedCtId && e . kicadSymbolId === mappedKsId ) ;
if ( existing ) { idMap . set ( ctks . id , existing . id ) ; } else {
const newCtks = { ... ctks , id : uuid ( ) , componentTypeId : mappedCtId , kicadSymbolId : mappedKsId } ;
idMap . set ( ctks . id , newCtks . id ) ;
await db . addComponentTypeKicadSymbol ( newCtks ) ;
existingCTKSs . push ( newCtks ) ;
}
}
}
2025-12-21 00:31:26 +02:00
// Device Export
async function exportDeviceZIP ( ) {
2026-04-24 17:28:59 +03:00
if ( ! currentDeviceId ) return ;
2026-05-23 11:18:19 +03:00
const pass = await requestPassphrase ( 'Protect Device Export with Passphrase?' , { allowEmpty : true , confirmNew : true } ) ;
if ( pass === null ) return ; // cancelled
2025-12-21 00:31:26 +02:00
2026-05-23 11:18:19 +03:00
const { BlobWriter , BlobReader , TextReader , ZipWriter } = zip ;
2025-12-21 00:31:26 +02:00
const dev = deviceList . find ( d => d . id === currentDeviceId ) ;
const boms = await db . getProjectsByDevice ( currentDeviceId ) ;
2025-12-21 18:45:40 +02:00
const allNets = await db . getNets ( ) ;
2026-04-24 17:28:59 +03:00
const allSchemas = await db . getSchemas ( ) ;
const allSchemaComps = await db . getSchemaComponents ( ) ;
2025-12-21 00:31:26 +02:00
2026-05-23 11:18:19 +03:00
const zipBlobWriter = new BlobWriter ( 'application/zip' ) ;
const zipWriter = new ZipWriter ( zipBlobWriter ) ; // encryption controlled per entry
const encOpts = pass ? { password : pass , encryptionStrength : 3 } : { } ;
const encNote = pass ? `
ENCRYPTED FILE NOTE :
README . txt is unencrypted and always readable .
All other files require the passphrase to extract .
Recommended extraction tools ( outside PCB ReTrace ) :
Windows : 7 - Zip ( 7 - zip . org ) — free
macOS : The Unarchiver or Keka — free
Linux : GNOME Archive Manager ( built - in ) or p7zip ( "7z x filename.zip" )
macOS built - in Archive Utility and Linux "unzip" command do not
support AES - 256 ZIP encryption — use the tools listed above .
` : '';
2025-12-21 00:31:26 +02:00
const readmeContent = ` PCB ReTrace Data Export
Type : Full Device Backup ( $ { dev . name } )
Generated by : pcb . etaras . com
Date : $ { new Date ( ) . toISOString ( ) }
2026-05-23 11:18:19 +03:00
$ { pass ? 'Protection: AES-256 encrypted — requires passphrase to open.\n' : '' }
2025-12-21 00:31:26 +02:00
HOW TO USE :
1. Go to https : //pcb.etaras.com/studio.html
2. Click "Import Device" or drag and drop this ZIP file into the tool .
2026-05-23 11:18:19 +03:00
$ { encNote }
2025-12-21 00:31:26 +02:00
` ;
2026-05-23 11:18:19 +03:00
await zipWriter . add ( 'README.txt' , new TextReader ( readmeContent ) ) ;
2025-12-21 00:31:26 +02:00
2026-05-23 11:18:19 +03:00
const manifest = { device : dev , version : db . ver , source : 'pcb.etaras.com' , boards : [ ] } ;
2025-12-21 00:31:26 +02:00
for ( const bom of boms ) {
const comps = await db . getComponents ( bom . id ) ;
2026-05-23 11:18:19 +03:00
const imgs = await db . getImages ( bom . id ) ;
const boardNets = allNets . filter ( n => n . projectId === bom . id ) ;
const boardSchemas = allSchemas . filter ( s => s . boardId === bom . id ) ;
const boardSchemaIds = new Set ( boardSchemas . map ( s => s . id ) ) ;
2026-04-24 17:28:59 +03:00
const boardSchemaComps = allSchemaComps . filter ( sc => boardSchemaIds . has ( sc . schemaId ) ) ;
2025-12-21 00:31:26 +02:00
const overlapsMap = new Map ( ) ;
2026-04-24 17:28:59 +03:00
for ( const img of imgs ) {
2025-12-21 00:31:26 +02:00
const ovs = await db . getOverlapsForImage ( img . id ) ;
ovs . forEach ( o => overlapsMap . set ( o . id , o ) ) ;
}
manifest . boards . push ( {
meta : bom ,
2026-05-23 11:18:19 +03:00
components : comps . map ( c => { const { boardId , ... rest } = c ; return rest ; } ) ,
images : imgs . map ( img => ( { id : img . id , name : img . name , type : 'image/jpeg' } ) ) ,
2025-12-21 00:31:26 +02:00
overlaps : Array . from ( overlapsMap . values ( ) ) ,
2026-05-23 11:18:19 +03:00
nets : boardNets , schemas : boardSchemas , schemaComponents : boardSchemaComps
2025-12-21 00:31:26 +02:00
} ) ;
for ( const img of imgs ) {
let blobToSave = img . blob ;
if ( img . blob . type !== 'image/jpeg' ) {
const bmp = await createImageBitmap ( img . blob ) ;
const canvas = document . createElement ( 'canvas' ) ;
2026-05-23 11:18:19 +03:00
canvas . width = bmp . width ; canvas . height = bmp . height ;
canvas . getContext ( '2d' ) . drawImage ( bmp , 0 , 0 ) ; bmp . close ( ) ;
2025-12-21 00:31:26 +02:00
blobToSave = await new Promise ( resolve => canvas . toBlob ( resolve , 'image/jpeg' , 0.85 ) ) ;
}
2026-05-23 11:18:19 +03:00
await zipWriter . add ( ` images/ ${ img . id } .jpg ` , new BlobReader ( blobToSave ) , encOpts ) ;
2025-12-21 00:31:26 +02:00
}
}
2026-04-24 17:28:59 +03:00
manifest . deviceSchemas = allSchemas . filter ( s => s . deviceId === dev . id && ! s . boardId ) ;
const devSchemaIds = new Set ( manifest . deviceSchemas . map ( s => s . id ) ) ;
manifest . deviceSchemaComponents = allSchemaComps . filter ( sc => devSchemaIds . has ( sc . schemaId ) ) ;
2026-05-23 11:18:19 +03:00
const exportedSchemaComps = manifest . deviceSchemaComponents . concat ( manifest . boards . flatMap ( b => b . schemaComponents || [ ] ) ) ;
2026-04-24 17:28:59 +03:00
manifest . schemaDependencies = await gatherSchemaDependencies ( exportedSchemaComps ) ;
2026-05-23 11:18:19 +03:00
await zipWriter . add ( 'device.json' , new TextReader ( JSON . stringify ( manifest , null , 2 ) ) , encOpts ) ;
await zipWriter . close ( ) ;
dl ( await zipBlobWriter . getData ( ) , ` ${ dev . name } _Backup.zip ` , 'application/zip' ) ;
2025-12-21 00:31:26 +02:00
}
function isValidBoardData ( data ) {
// 1. Root Object check
if ( ! data || typeof data !== 'object' ) return false ;
// 2. Meta Object check
if ( ! data . meta || typeof data . meta !== 'object' ) return false ;
// ID must be a string and not empty
if ( typeof data . meta . id !== 'string' || ! data . meta . id . trim ( ) ) return false ;
// Name must be a string (can be empty, but must exist)
if ( typeof data . meta . name !== 'string' ) return false ;
// 3. Components Array check
if ( ! Array . isArray ( data . components ) ) return false ;
// 4. Images Array check (optional but must be array if present)
if ( data . images && ! Array . isArray ( data . images ) ) return false ;
return true ;
}
function isValidDeviceManifest ( data ) {
if ( ! data || typeof data !== 'object' ) return false ;
// 1. Device Object check
if ( ! data . device || typeof data . device !== 'object' ) return false ;
if ( typeof data . device . id !== 'string' || ! data . device . id . trim ( ) ) return false ;
if ( typeof data . device . name !== 'string' ) return false ;
// 2. Boards Array check
if ( ! Array . isArray ( data . boards ) ) return false ;
// 3. Version check (optional warning could be added here, but structural check is pass/fail)
return true ;
}
function isValidLegacyData ( data ) {
// Case A: Top-level Array of objects
if ( Array . isArray ( data ) ) return true ;
// Case B: Object with 'components' or 'data' array
if ( data && typeof data === 'object' ) {
return Array . isArray ( data . components ) || Array . isArray ( data . data ) ;
}
return false ;
}
async function importFile ( f ) {
2026-04-24 17:28:59 +03:00
if ( ! f ) return ;
2025-12-21 00:31:26 +02:00
const name = f . name . toLowerCase ( ) ;
// 1. ZIP Import (Device Backup or Board Export)
2026-04-24 17:28:59 +03:00
if ( name . endsWith ( '.zip' ) ) {
2025-12-21 00:31:26 +02:00
await processZIP ( f ) ;
return ;
}
// 2. JSON Import (Metadata only)
2026-04-24 17:28:59 +03:00
if ( name . endsWith ( '.json' ) ) {
2025-12-21 00:31:26 +02:00
const r = new FileReader ( ) ;
r . onload = async e => {
try {
const json = JSON . parse ( e . target . result ) ;
if ( isValidBoardData ( json ) ) {
if ( ! currentDeviceId ) return alert ( "Select a Device first." ) ;
2026-04-24 17:28:59 +03:00
if ( confirm ( ` Import Board " ${ json . meta . name } " from JSON? ` ) ) {
2025-12-21 00:31:26 +02:00
json . meta . deviceId = currentDeviceId ;
await processImportData ( json , null ) ;
}
return ;
}
if ( isValidDeviceManifest ( json ) ) {
alert ( "Device Manifests should be imported via ZIP to include images.\nImporting metadata only." ) ;
2026-04-24 17:28:59 +03:00
if ( confirm ( "Proceed with metadata-only import?" ) ) {
2025-12-21 00:31:26 +02:00
await restoreDevice ( json , null ) ;
}
return ;
}
if ( isValidLegacyData ( json ) ) {
2026-04-24 17:28:59 +03:00
if ( confirm ( "Detected Legacy BOM format. Import into current board?" ) ) {
2025-12-21 00:31:26 +02:00
await processLegacyImport ( json ) ;
}
return ;
}
throw new Error ( "JSON structure does not match known schemas." ) ;
2026-04-24 17:28:59 +03:00
} catch ( e ) {
2025-12-21 00:31:26 +02:00
console . error ( e ) ;
alert ( "Invalid JSON: " + e . message ) ;
}
} ;
r . readAsText ( f ) ;
return ;
}
// 3. Image Import (Drag & Drop -> Open Editor)
2026-04-24 17:28:59 +03:00
if ( /\.(jpg|jpeg|png|webp)$/i . test ( name ) ) {
2025-12-21 00:31:26 +02:00
// SAFETY: Ensure a board is currently active
2026-04-24 17:28:59 +03:00
if ( ! currentBomId ) {
2025-12-21 00:31:26 +02:00
alert ( "Cannot import image: No board selected.\nPlease select or create a board first." ) ;
return ;
}
// Read file as DataURL to pass to the Editor
const r = new FileReader ( ) ;
r . onload = e => {
if ( typeof ImageImporter !== 'undefined' ) {
// Pre-fill the name
2026-04-24 17:28:59 +03:00
if ( ImageImporter . nameInput ) {
2025-12-21 00:31:26 +02:00
ImageImporter . nameInput . value = f . name . replace ( /\.[^/.]+$/ , "" ) ;
}
// Open the Modal
ImageImporter . loadImage ( e . target . result ) ;
} else {
// Fallback if UI not loaded (unlikely)
2026-04-24 17:28:59 +03:00
if ( confirm ( ` Import image " ${ f . name } "? ` ) ) {
2025-12-21 00:31:26 +02:00
saveProcessedImageToDB ( f , f . name ) ;
}
}
} ;
r . readAsDataURL ( f ) ;
return ;
}
}
// Button Handler (keeps input element logic)
function handleImport ( i ) {
const f = i . files [ 0 ] ;
2026-04-24 17:28:59 +03:00
if ( f ) importFile ( f ) ;
2025-12-21 00:31:26 +02:00
i . value = '' ; // Reset input so same file can be selected again
}
function setupDragDrop ( ) {
const zone = document . body ;
let dragCounter = 0 ; // Fixes flickering when dragging over child elements
// 1. Drag Enter
zone . addEventListener ( 'dragenter' , e => {
e . preventDefault ( ) ;
dragCounter ++ ;
zone . classList . add ( 'drag-active' ) ;
} ) ;
// 2. Drag Leave
zone . addEventListener ( 'dragleave' , e => {
e . preventDefault ( ) ;
dragCounter -- ;
2026-04-24 17:28:59 +03:00
if ( dragCounter === 0 ) zone . classList . remove ( 'drag-active' ) ;
2025-12-21 00:31:26 +02:00
} ) ;
// 3. Drag Over (Required to allow dropping)
zone . addEventListener ( 'dragover' , e => e . preventDefault ( ) ) ;
// 4. Drop
zone . addEventListener ( 'drop' , async e => {
e . preventDefault ( ) ;
dragCounter = 0 ;
zone . classList . remove ( 'drag-active' ) ;
if ( e . dataTransfer . files && e . dataTransfer . files . length > 0 ) {
// Process only the first file
await importFile ( e . dataTransfer . files [ 0 ] ) ;
}
} ) ;
}
async function processZIP ( file ) {
2026-05-23 11:18:19 +03:00
const { BlobReader , BlobWriter , TextWriter , ZipReader } = zip ;
2025-12-21 00:31:26 +02:00
try {
2026-05-23 11:18:19 +03:00
// 1. Open without password to inspect entries
let reader = new ZipReader ( new BlobReader ( file ) ) ;
let entries = await reader . getEntries ( ) ;
await reader . close ( ) ;
// 2. Detect encryption and prompt for passphrase once
const isEncrypted = entries . some ( e => e . encrypted ) ;
let pass = null ;
if ( isEncrypted ) {
pass = await requestPassphrase ( 'This file is password protected.' , { } ) ;
if ( ! pass ) return ; // user cancelled — cannot proceed without passphrase
}
// 3. Re-open, reading all content (with password if needed)
reader = new ZipReader ( new BlobReader ( file ) , pass ? { password : pass } : { } ) ;
entries = await reader . getEntries ( ) ;
// 4. Extract text files and images into a flat Map in one pass
const texts = { } ; // filename → string
const imageMap = new Map ( ) ; // basename (e.g. "abc123.jpg") → Blob
for ( const entry of entries ) {
if ( entry . directory ) continue ;
const name = entry . filename ;
try {
if ( name === 'device.json' || name === 'bom.json' ) {
texts [ name ] = await entry . getData ( new TextWriter ( ) ) ;
} else if ( name . startsWith ( 'images/' ) ) {
const blob = await entry . getData ( new BlobWriter ( ) ) ;
imageMap . set ( name . split ( '/' ) . pop ( ) , blob ) ;
2025-12-21 00:31:26 +02:00
}
2026-05-23 11:18:19 +03:00
} catch ( e ) {
throw new Error ( ` Failed to read " ${ name } ". ${ isEncrypted ? 'Check your passphrase.' : e . message } ` ) ;
2025-12-21 00:31:26 +02:00
}
}
2026-05-23 11:18:19 +03:00
await reader . close ( ) ;
2025-12-21 00:31:26 +02:00
2026-05-23 11:18:19 +03:00
// 5. Route to correct handler
if ( texts [ 'device.json' ] ) {
let manifest ;
try { manifest = JSON . parse ( texts [ 'device.json' ] ) ; }
catch ( e ) { throw new Error ( 'device.json is corrupt.' ) ; }
if ( ! isValidDeviceManifest ( manifest ) )
throw new Error ( 'ZIP contains device.json, but it is missing required fields (id, name, boards).' ) ;
const vMsg = manifest . version ? ` (v ${ manifest . version } ) ` : '' ;
const sMsg = manifest . source ? ` \n Source: ${ manifest . source } ` : '' ;
if ( confirm ( ` Restore Device: " ${ manifest . device . name } " ${ vMsg } ? ${ sMsg } \n \n This will merge boards and overwrite existing components. ` ) )
await restoreDevice ( manifest , imageMap ) ;
return ;
}
2025-12-21 00:31:26 +02:00
2026-05-23 11:18:19 +03:00
if ( texts [ 'bom.json' ] ) {
let data ;
try { data = JSON . parse ( texts [ 'bom.json' ] ) ; }
catch ( e ) { throw new Error ( 'bom.json is corrupt.' ) ; }
if ( ! isValidBoardData ( data ) )
throw new Error ( 'ZIP contains bom.json, but it is missing required fields (id, meta, components).' ) ;
if ( ! currentDeviceId ) { alert ( 'Please select or create a Device first.' ) ; return ; }
if ( confirm ( ` Import Board: " ${ data . meta . name } " into current Device? ` ) ) {
data . meta . deviceId = currentDeviceId ;
await processImportData ( data , imageMap ) ;
2025-12-21 00:31:26 +02:00
}
2026-05-23 11:18:19 +03:00
return ;
2025-12-21 00:31:26 +02:00
}
2026-05-23 11:18:19 +03:00
alert ( "Unrecognized ZIP format.\n\nExpected 'device.json' or 'bom.json' with valid structure." ) ;
2025-12-21 00:31:26 +02:00
2026-04-24 17:28:59 +03:00
} catch ( e ) {
2026-05-23 11:18:19 +03:00
const msg = e . message || '' ;
if ( msg . toLowerCase ( ) . includes ( 'passphrase' ) || msg . toLowerCase ( ) . includes ( 'decrypt' ) ) {
// Wrong passphrase — retry without restarting the import
const retry = await requestPassphrase ( 'Incorrect passphrase — try again.' , { } ) ;
if ( retry ) return processZIP ( file ) ; // restart with same file
return ;
}
alert ( 'Import Failed: ' + msg ) ;
2025-12-21 00:31:26 +02:00
}
}
2026-05-23 11:18:19 +03:00
async function restoreDevice ( manifest , imageMap ) {
2025-12-21 00:31:26 +02:00
const devId = manifest . device . id ;
const existingDev = deviceList . find ( d => d . id === devId ) ;
if ( ! existingDev ) {
await db . addDevice ( manifest . device ) ;
}
2026-04-24 17:28:59 +03:00
const idMap = new Map ( ) ;
if ( manifest . schemaDependencies ) {
await importSchemaDependencies ( manifest . schemaDependencies , idMap ) ;
}
2025-12-21 00:31:26 +02:00
// --- PRUNING PHASE 1: BOARDS ---
const localBoards = await db . getProjectsByDevice ( devId ) ;
const manifestBoardIds = new Set ( manifest . boards . map ( b => b . meta . id ) ) ;
for ( const lb of localBoards ) {
if ( ! manifestBoardIds . has ( lb . id ) ) {
await db . deleteProject ( lb . id ) ;
2025-12-21 18:45:40 +02:00
// 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.
2025-12-21 00:31:26 +02:00
console . log ( ` Pruned Board: ${ lb . name } ` ) ;
}
}
let updatedBoards = 0 ;
let mergedBoards = 0 ;
for ( const boardData of manifest . boards ) {
const boardId = boardData . meta . id ;
const localBoard = await db . getProject ( boardId ) ;
// Timestamp Logic
const incomingTime = boardData . meta . lastModified || 0 ;
const localTime = localBoard ? ( localBoard . lastModified || 0 ) : - 1 ;
const isNewer = incomingTime > localTime ;
if ( isNewer ) {
// Update Meta
boardData . meta . deviceId = devId ;
await db . addProject ( boardData . meta ) ;
2026-04-24 17:28:59 +03:00
if ( localBoard ) updatedBoards ++ ;
2025-12-21 00:31:26 +02:00
// --- PRUNING PHASE 2: CONTENT ---
// 1. Prune Components
const localComps = await db . getComponents ( boardId ) ;
const importCompIds = new Set ( boardData . components . map ( c => c . id ) ) ;
2026-04-24 17:28:59 +03:00
for ( const lc of localComps ) {
if ( ! importCompIds . has ( lc . id ) ) await db . deleteComponent ( lc . id ) ;
2025-12-21 00:31:26 +02:00
}
// 2. Prune Images
const localImages = await db . getImages ( boardId ) ;
const importImgIds = new Set ( boardData . images . map ( i => i . id ) ) ;
2026-04-24 17:28:59 +03:00
for ( const li of localImages ) {
if ( ! importImgIds . has ( li . id ) ) await db . deleteImage ( li . id ) ;
2025-12-21 00:31:26 +02:00
}
// 3. Prune Overlaps
2026-04-24 17:28:59 +03:00
if ( localImages . length > 0 ) {
2025-12-21 00:31:26 +02:00
const boardOverlapIds = new Set ( ) ;
2026-04-24 17:28:59 +03:00
for ( const li of localImages ) {
2025-12-21 00:31:26 +02:00
const ovs = await db . getOverlapsForImage ( li . id ) ;
ovs . forEach ( o => boardOverlapIds . add ( o . id ) ) ;
}
const importOvIds = new Set ( ( boardData . overlaps || [ ] ) . map ( o => o . id ) ) ;
const tx = db . db . transaction ( 'overlappedImages' , 'readwrite' ) ;
const store = tx . objectStore ( 'overlappedImages' ) ;
boardOverlapIds . forEach ( ovid => {
2026-04-24 17:28:59 +03:00
if ( ! importOvIds . has ( ovid ) ) store . delete ( ovid ) ;
2025-12-21 00:31:26 +02:00
} ) ;
}
2026-04-24 17:28:59 +03:00
// 4. Prune Nets
2025-12-21 18:45:40 +02:00
const allNets = await db . getNets ( ) ;
const localNets = allNets . filter ( n => n . projectId === boardId ) ;
const importNetIds = new Set ( ( boardData . nets || [ ] ) . map ( n => n . id ) ) ;
2026-04-24 17:28:59 +03:00
for ( const ln of localNets ) {
if ( ! importNetIds . has ( ln . id ) ) await db . deleteNet ( ln . id ) ;
}
// 5. Prune Schemas
const allSchemas = await db . getSchemas ( ) ;
const localSchemas = allSchemas . filter ( s => s . boardId === boardId ) ;
const importSchemaIds = new Set ( ( boardData . schemas || [ ] ) . map ( s => s . id ) ) ;
for ( const ls of localSchemas ) {
if ( ! importSchemaIds . has ( ls . id ) ) await db . deleteSchema ( ls . id ) ;
2025-12-21 18:45:40 +02:00
}
2025-12-21 00:31:26 +02:00
} else {
mergedBoards ++ ;
}
// --- UPSERT PHASE ---
2026-05-23 11:18:19 +03:00
// Images
if ( imageMap && boardData . images ) {
2025-12-21 00:31:26 +02:00
for ( const im of boardData . images ) {
if ( ! isNewer ) {
const existingImg = await db . getImage ( im . id ) ;
if ( existingImg ) continue ;
}
2026-05-23 11:18:19 +03:00
const cleanId = im . id . split ( '/' ) . pop ( ) ;
const ext = ( im . type || 'image/jpeg' ) . split ( '/' ) [ 1 ] || 'jpg' ;
const blob = imageMap . get ( cleanId + '.jpg' ) || imageMap . get ( cleanId + '.' + ext ) ;
if ( blob ) {
2025-12-21 00:31:26 +02:00
const cleanName = im . name . replace ( /\.[^/.]+$/ , "" ) ;
await db . addImage ( { id : im . id , boardId : boardId , blob , name : cleanName } ) ;
}
}
}
// Components
for ( const c of boardData . components ) {
c . boardId = boardId ;
if ( isNewer ) {
await db . addComponent ( c ) ;
} else {
const existingC = await db . _tx ( 'components' , 'readonly' , s => s . get ( c . id ) ) ;
if ( ! existingC ) await db . addComponent ( c ) ;
}
}
// Overlaps
if ( boardData . overlaps ) {
for ( const ov of boardData . overlaps ) {
if ( isNewer ) {
await db . addOverlap ( ov ) ;
} else {
const existingOv = await db . _tx ( 'overlappedImages' , 'readonly' , s => s . get ( ov . id ) ) ;
if ( ! existingOv ) await db . addOverlap ( ov ) ;
}
}
}
2026-04-24 17:28:59 +03:00
// Nets
2025-12-21 18:45:40 +02:00
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 ) ;
}
}
}
2026-04-24 17:28:59 +03:00
// Schemas
if ( boardData . schemas ) {
for ( const schema of boardData . schemas ) {
schema . boardId = boardId ;
schema . deviceId = devId ;
if ( isNewer ) {
await db . addSchema ( schema ) ;
} else {
const existing = await db . _tx ( 'schemas' , 'readonly' , s => s . get ( schema . id ) ) ;
if ( ! existing ) await db . addSchema ( schema ) ;
}
}
}
// SchemaComponents
if ( boardData . schemaComponents ) {
for ( const sc of boardData . schemaComponents ) {
if ( sc . componentTypeId && idMap . has ( sc . componentTypeId ) ) {
sc . componentTypeId = idMap . get ( sc . componentTypeId ) ;
}
if ( isNewer ) {
await db . addSchemaComponent ( sc ) ;
} else {
const existing = await db . _tx ( 'schemaComponents' , 'readonly' , s => s . get ( sc . id ) ) ;
if ( ! existing ) await db . addSchemaComponent ( sc ) ;
}
}
}
}
// Upsert device-level schemas
if ( manifest . deviceSchemas ) {
for ( const schema of manifest . deviceSchemas ) {
schema . deviceId = devId ;
schema . boardId = null ;
const existing = await db . _tx ( 'schemas' , 'readonly' , s => s . get ( schema . id ) ) ;
if ( ! existing ) await db . addSchema ( schema ) ;
}
}
if ( manifest . deviceSchemaComponents ) {
for ( const sc of manifest . deviceSchemaComponents ) {
if ( sc . componentTypeId && idMap . has ( sc . componentTypeId ) ) {
sc . componentTypeId = idMap . get ( sc . componentTypeId ) ;
}
const existing = await db . _tx ( 'schemaComponents' , 'readonly' , s => s . get ( sc . id ) ) ;
if ( ! existing ) await db . addSchemaComponent ( sc ) ;
}
2025-12-21 00:31:26 +02:00
}
deviceList = await db . getDevices ( ) ;
currentDeviceId = devId ;
updateDeviceDropdown ( ) ;
await loadDeviceBoms ( ) ;
}
2026-05-23 11:18:19 +03:00
async function processImportData ( data , imageMap ) {
2026-04-24 17:28:59 +03:00
const idMap = new Map ( ) ;
if ( data . schemaDependencies ) {
await importSchemaDependencies ( data . schemaDependencies , idMap ) ;
}
2025-12-21 00:31:26 +02:00
const boardId = data . meta . id ;
const localBoard = await db . getProject ( boardId ) ;
const incomingTime = data . meta . lastModified || 0 ;
const localTime = localBoard ? ( localBoard . lastModified || 0 ) : - 1 ;
const isNewer = incomingTime > localTime ;
if ( isNewer ) {
// Update Meta
data . meta . deviceId = localBoard ? localBoard . deviceId : currentDeviceId ;
await db . addProject ( data . meta ) ;
// --- PRUNING PHASE ---
// 1. Components
const localComps = await db . getComponents ( boardId ) ;
const importCompIds = new Set ( data . components . map ( c => c . id ) ) ;
2026-04-24 17:28:59 +03:00
for ( const lc of localComps ) {
if ( ! importCompIds . has ( lc . id ) ) await db . deleteComponent ( lc . id ) ;
2025-12-21 00:31:26 +02:00
}
// 2. Images
const localImages = await db . getImages ( boardId ) ;
const importImgIds = new Set ( ( data . images || [ ] ) . map ( i => i . id ) ) ;
2026-04-24 17:28:59 +03:00
for ( const li of localImages ) {
if ( ! importImgIds . has ( li . id ) ) await db . deleteImage ( li . id ) ;
2025-12-21 00:31:26 +02:00
}
// 3. Overlaps
2026-04-24 17:28:59 +03:00
if ( localImages . length > 0 ) {
2025-12-21 00:31:26 +02:00
const boardOverlapIds = new Set ( ) ;
2026-04-24 17:28:59 +03:00
for ( const li of localImages ) {
2025-12-21 00:31:26 +02:00
const ovs = await db . getOverlapsForImage ( li . id ) ;
ovs . forEach ( o => boardOverlapIds . add ( o . id ) ) ;
}
const importOvIds = new Set ( ( data . overlaps || [ ] ) . map ( o => o . id ) ) ;
const tx = db . db . transaction ( 'overlappedImages' , 'readwrite' ) ;
const store = tx . objectStore ( 'overlappedImages' ) ;
boardOverlapIds . forEach ( ovid => {
2026-04-24 17:28:59 +03:00
if ( ! importOvIds . has ( ovid ) ) store . delete ( ovid ) ;
2025-12-21 00:31:26 +02:00
} ) ;
}
2026-04-24 17:28:59 +03:00
// 4. Nets
2025-12-21 18:45:40 +02:00
const allNets = await db . getNets ( ) ;
const localNets = allNets . filter ( n => n . projectId === boardId ) ;
const importNetIds = new Set ( ( data . nets || [ ] ) . map ( n => n . id ) ) ;
2026-04-24 17:28:59 +03:00
for ( const ln of localNets ) {
if ( ! importNetIds . has ( ln . id ) ) await db . deleteNet ( ln . id ) ;
2025-12-21 18:45:40 +02:00
}
2025-12-21 00:31:26 +02:00
2026-04-24 17:28:59 +03:00
// 5. Schemas
const allSchemas = await db . getSchemas ( ) ;
const localSchemas = allSchemas . filter ( s => s . boardId === boardId ) ;
const safeSchemas = data . schemas || new Array ( ) ;
const importSchemaIds = new Set ( safeSchemas . map ( s => s . id ) ) ;
for ( const ls of localSchemas ) {
if ( ! importSchemaIds . has ( ls . id ) ) await db . deleteSchema ( ls . id ) ;
}
2025-12-21 00:31:26 +02:00
} else {
console . log ( "Import is older/same. Merging missing items only." ) ;
}
// --- UPSERT PHASE ---
2026-05-23 11:18:19 +03:00
// Images
if ( imageMap && data . images ) {
for ( const im of data . images ) {
if ( ! isNewer ) {
const existing = await db . getImage ( im . id ) ;
if ( existing ) continue ;
}
const cleanId = im . id . split ( '/' ) . pop ( ) ;
const ext = ( im . type || 'image/jpeg' ) . split ( '/' ) [ 1 ] || 'jpg' ;
const rawBlob = imageMap . get ( cleanId + '.jpg' ) || imageMap . get ( cleanId + '.' + ext ) ;
if ( rawBlob ) {
const blob = new Blob ( [ rawBlob ] , { type : im . type || 'image/jpeg' } ) ;
const cleanName = im . name . replace ( /\.[^/.]+$/ , "" ) ;
await db . addImage ( { id : im . id , boardId : boardId , blob , name : cleanName } ) ;
2025-12-21 00:31:26 +02:00
}
}
}
// Components
2026-04-24 17:28:59 +03:00
for ( const c of data . components ) {
2025-12-21 00:31:26 +02:00
c . boardId = boardId ;
if ( isNewer ) {
await db . addComponent ( c ) ;
} else {
const existing = await db . _tx ( 'components' , 'readonly' , s => s . get ( c . id ) ) ;
if ( ! existing ) await db . addComponent ( c ) ;
}
}
// Overlaps
if ( data . overlaps ) {
for ( const ov of data . overlaps ) {
if ( isNewer ) {
await db . addOverlap ( ov ) ;
} else {
const existing = await db . _tx ( 'overlappedImages' , 'readonly' , s => s . get ( ov . id ) ) ;
if ( ! existing ) await db . addOverlap ( ov ) ;
}
}
}
2026-04-24 17:28:59 +03:00
// Nets
2025-12-21 18:45:40 +02:00
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 ) ;
}
}
}
2025-12-21 00:31:26 +02:00
2026-04-24 17:28:59 +03:00
// Schemas
if ( data . schemas ) {
for ( const schema of data . schemas ) {
schema . boardId = boardId ;
if ( isNewer ) {
await db . addSchema ( schema ) ;
} else {
const existing = await db . _tx ( 'schemas' , 'readonly' , s => s . get ( schema . id ) ) ;
if ( ! existing ) await db . addSchema ( schema ) ;
}
}
}
// SchemaComponents
if ( data . schemaComponents ) {
for ( const sc of data . schemaComponents ) {
// Map the componentTypeId to the newly generated one (if it was created during import)
if ( sc . componentTypeId && idMap . has ( sc . componentTypeId ) ) {
sc . componentTypeId = idMap . get ( sc . componentTypeId ) ;
}
if ( isNewer ) {
await db . addSchemaComponent ( sc ) ;
} else {
const existing = await db . _tx ( 'schemaComponents' , 'readonly' , s => s . get ( sc . id ) ) ;
if ( ! existing ) await db . addSchemaComponent ( sc ) ;
}
}
}
2025-12-21 00:31:26 +02:00
await loadDeviceBoms ( ) ;
}
async function processLegacyImport ( b ) {
if ( ! currentBomId ) return alert ( "Create/Select a board first." ) ;
2026-04-24 17:28:59 +03:00
for ( const c of ( b . components || b . data ) ) { c . id = uuid ( ) ; c . boardId = currentBomId ; await db . addComponent ( c ) ; }
2025-12-21 00:31:26 +02:00
await loadProjectData ( ) ;
}
// Board Export
async function exportZIP ( ) {
2026-05-23 11:18:19 +03:00
const pass = await requestPassphrase ( 'Protect Board Export with Passphrase?' , { allowEmpty : true , confirmNew : true } ) ;
if ( pass === null ) return ; // cancelled
2025-12-21 00:31:26 +02:00
2026-05-23 11:18:19 +03:00
const { BlobWriter , BlobReader , TextReader , ZipWriter } = zip ;
const meta = bomList . find ( x => x . id === currentBomId ) ;
const images = await db . getImages ( currentBomId ) ;
2025-12-21 18:45:40 +02:00
const allNets = await db . getNets ( ) ;
const boardNets = allNets . filter ( n => n . projectId === currentBomId ) ;
2025-12-21 00:31:26 +02:00
const overlapsMap = new Map ( ) ;
2026-04-24 17:28:59 +03:00
for ( const img of images ) {
2025-12-21 00:31:26 +02:00
const ovs = await db . getOverlapsForImage ( img . id ) ;
ovs . forEach ( o => overlapsMap . set ( o . id , o ) ) ;
}
2026-04-24 17:28:59 +03:00
const allSchemas = await db . getSchemas ( ) ;
const boardSchemas = allSchemas . filter ( s => s . boardId === currentBomId ) ;
const boardSchemaIds = new Set ( boardSchemas . map ( s => s . id ) ) ;
const allSchemaComps = await db . getSchemaComponents ( ) ;
const boardSchemaComps = allSchemaComps . filter ( sc => boardSchemaIds . has ( sc . schemaId ) ) ;
const schemaDeps = await gatherSchemaDependencies ( boardSchemaComps ) ;
2026-05-23 11:18:19 +03:00
const encNote = pass ? `
ENCRYPTED FILE NOTE :
README . txt is unencrypted and always readable .
All other files require the passphrase to extract .
Recommended extraction tools ( outside PCB ReTrace ) :
Windows : 7 - Zip ( 7 - zip . org ) — free
macOS : The Unarchiver or Keka — free
Linux : GNOME Archive Manager ( built - in ) or p7zip ( "7z x filename.zip" )
macOS built - in Archive Utility and Linux "unzip" command do not
support AES - 256 ZIP encryption — use the tools listed above .
` : '';
2025-12-21 00:31:26 +02:00
const readmeContent = ` PCB ReTrace Data Export
Type : Single Board ( $ { meta . name } )
Generated by : pcb . etaras . com
Date : $ { new Date ( ) . toISOString ( ) }
2026-05-23 11:18:19 +03:00
$ { pass ? 'Protection: AES-256 encrypted — requires passphrase to open.\n' : '' }
2025-12-21 00:31:26 +02:00
HOW TO USE :
1. Go to https : //pcb.etaras.com/studio.html
2. Select or Create a Device .
3. Click "Import Board" or drag and drop this ZIP file into the component list .
2026-05-23 11:18:19 +03:00
$ { encNote }
2025-12-21 00:31:26 +02:00
` ;
const data = {
2026-05-23 11:18:19 +03:00
meta , version : db . ver , source : 'pcb.etaras.com' ,
components : bomData . map ( c => { const { boardId , ... rest } = c ; return rest ; } ) ,
images : images . map ( i => ( { id : i . id , name : i . name , type : 'image/jpeg' } ) ) ,
overlaps : Array . from ( overlapsMap . values ( ) ) ,
nets : boardNets , schemas : boardSchemas , schemaComponents : boardSchemaComps ,
schemaDependencies : schemaDeps
2025-12-21 00:31:26 +02:00
} ;
2026-05-23 11:18:19 +03:00
const zipBlobWriter = new BlobWriter ( 'application/zip' ) ;
const zipWriter = new ZipWriter ( zipBlobWriter ) ; // encryption controlled per entry
const encOpts = pass ? { password : pass , encryptionStrength : 3 } : { } ;
2025-12-21 00:31:26 +02:00
2026-05-23 11:18:19 +03:00
await zipWriter . add ( 'README.txt' , new TextReader ( readmeContent ) ) ;
await zipWriter . add ( 'bom.json' , new TextReader ( JSON . stringify ( data , null , 2 ) ) , encOpts ) ;
2025-12-21 00:31:26 +02:00
for ( const img of images ) {
let blobToSave = img . blob ;
if ( img . blob . type !== 'image/jpeg' ) {
const bmp = await createImageBitmap ( img . blob ) ;
const canvas = document . createElement ( 'canvas' ) ;
2026-05-23 11:18:19 +03:00
canvas . width = bmp . width ; canvas . height = bmp . height ;
canvas . getContext ( '2d' ) . drawImage ( bmp , 0 , 0 ) ; bmp . close ( ) ;
2025-12-21 00:31:26 +02:00
blobToSave = await new Promise ( resolve => canvas . toBlob ( resolve , 'image/jpeg' , 0.85 ) ) ;
}
2026-05-23 11:18:19 +03:00
await zipWriter . add ( ` images/ ${ img . id } .jpg ` , new BlobReader ( blobToSave ) , encOpts ) ;
2025-12-21 00:31:26 +02:00
}
2026-05-23 11:18:19 +03:00
await zipWriter . close ( ) ;
dl ( await zipBlobWriter . getData ( ) , ` ${ meta . name } _Board.zip ` , 'application/zip' ) ;
2025-12-21 00:31:26 +02:00
}
function exportCSV ( ) {
const meta = bomList . find ( p => p . id === currentBomId ) ;
const view = getSortedView ( ) ;
let csv = "Reference,Value,Description\n" ;
2026-04-24 17:28:59 +03:00
view . forEach ( r => csv += ` ${ r . label } , ${ r . value } ," ${ ( r . desc || '' ) . replace ( /"/g , '""' ) } " \n ` ) ;
2025-12-21 00:31:26 +02:00
const blob = new Blob ( [ csv ] , { type : 'text/csv' } ) ;
const url = window . URL . createObjectURL ( blob ) ;
const a = document . createElement ( 'a' ) ; a . href = url ; a . download = ` ${ meta . name } _BOM.csv ` ; document . body . appendChild ( a ) ; a . click ( ) ; document . body . removeChild ( a ) ;
}
2026-04-24 17:28:59 +03:00
function dl ( c , n , t ) { const b = ( c instanceof Blob ) ? c : new Blob ( [ c ] , { type : t } ) ; const u = window . URL . createObjectURL ( b ) ; const a = document . createElement ( 'a' ) ; a . href = u ; a . download = n ; document . body . appendChild ( a ) ; a . click ( ) ; document . body . removeChild ( a ) ; }
2025-12-21 00:31:26 +02:00
// --- UI HELPERS ---
// Global Confirmation Helper (Promise-based)
function confirmAction ( message , btnText = "Confirm" ) {
return new Promise ( ( resolve ) => {
const modal = document . getElementById ( 'confirmation-modal' ) ;
const msgEl = document . getElementById ( 'confirm-msg' ) ;
const okBtn = document . getElementById ( 'confirm-btn-ok' ) ;
const cancelBtn = document . getElementById ( 'confirm-btn-cancel' ) ;
2026-04-27 18:02:04 +03:00
const closeBtn = modal . querySelector ( '.close-btn' ) ;
const modalContext = 'confirmation-modal' ;
2025-12-21 00:31:26 +02:00
msgEl . innerText = message ;
okBtn . innerText = btnText ;
2026-04-27 18:02:04 +03:00
let resultToResolve = false ;
// 1. Cleanup & Resolve
const close = ( ) => {
window . removeEventListener ( 'popstate' , onPopState ) ;
2025-12-21 00:31:26 +02:00
modal . style . display = 'none' ;
2026-04-27 18:02:04 +03:00
resolve ( resultToResolve ) ;
2025-12-21 00:31:26 +02:00
} ;
2026-04-27 18:02:04 +03:00
// 2. Handle History Changes (Back Button / Escape)
const onPopState = ( ) => {
close ( ) ;
} ;
// 3. Handle UI Actions (OK / Cancel)
const commit = ( res ) => {
resultToResolve = res ;
if ( history . state && history . state . context === modalContext ) {
history . back ( ) ; // This triggers onPopState -> close()
} else {
close ( ) ;
}
2025-12-21 00:31:26 +02:00
} ;
2026-04-27 18:02:04 +03:00
// 4. Setup DOM (Clone to cleanly remove old listeners)
const newOk = okBtn . cloneNode ( true ) ;
const newCancel = cancelBtn . cloneNode ( true ) ;
okBtn . replaceWith ( newOk ) ;
cancelBtn . replaceWith ( newCancel ) ;
newOk . onclick = ( e ) => { e . stopPropagation ( ) ; commit ( true ) ; } ;
newCancel . onclick = ( e ) => { e . stopPropagation ( ) ; commit ( false ) ; } ;
closeBtn . onclick = ( e ) => { e . stopPropagation ( ) ; e . preventDefault ( ) ; commit ( false ) ; } ;
// 5. Open & Push State
if ( ! history . state || history . state . context !== modalContext ) {
history . pushState ( { context : modalContext } , "" , "" ) ;
}
window . addEventListener ( 'popstate' , onPopState ) ;
2025-12-21 00:31:26 +02:00
modal . style . display = 'flex' ;
2026-04-27 18:02:04 +03:00
newCancel . focus ( ) ; // Default focus on Cancel
2025-12-21 00:31:26 +02:00
} ) ;
}
function switchView ( v ) {
// UI Toggles
document . querySelectorAll ( '.tab-btn' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
document . querySelectorAll ( '.view-section' ) . forEach ( s => s . classList . remove ( 'active' ) ) ;
// Button Active State
const btn = document . getElementById ( ` tab- ${ v } ` ) ;
if ( btn ) btn . classList . add ( 'active' ) ;
// View Active State
const view = document . getElementById ( ` view- ${ v } ` ) ;
if ( view ) view . classList . add ( 'active' ) ;
2026-04-24 17:28:59 +03:00
// const schemaDiv = document.getElementById('view-schema');
// if (schemaDiv) {
// console.log('[studio] view-schema computed display:',
// window.getComputedStyle(schemaDiv).display,
// 'clientWidth:', schemaDiv.clientWidth,
// 'clientHeight:', schemaDiv.clientHeight);
// }
2025-12-21 00:31:26 +02:00
// View Specific Initialization
if ( v === 'map' ) {
2026-04-24 17:28:59 +03:00
if ( currentImgId ) {
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 ) ; }
2025-12-21 00:31:26 +02:00
}
2025-12-21 18:45:40 +02:00
}
2025-12-21 00:31:26 +02:00
else if ( v === 'nets' ) {
2026-04-24 17:28:59 +03:00
if ( window . netManager ) window . netManager . render ( ) ;
2025-12-21 18:45:40 +02:00
}
2025-12-21 00:31:26 +02:00
else if ( v === 'inspect' ) {
2026-04-24 17:28:59 +03:00
if ( window . inspector ) window . inspector . init ( ) ;
}
else if ( v === 'schema' ) {
const frame = document . getElementById ( 'schema-frame' ) ;
if ( ! frame ) return ;
const mc = document . querySelector ( '.main-content' ) ;
const msg = {
type : 'SCHEMA_INIT' ,
boardId : currentBomId ,
deviceId : currentDeviceId ,
width : mc ? mc . clientWidth : window . innerWidth ,
height : mc ? mc . clientHeight : window . innerHeight
} ;
if ( ! frame . _schemaReady ) {
// First visit — load the page, send message after load
// console.log('[studio] setting frame.src, _schemaReady:', frame._schemaReady,
// 'boardId:', currentBomId);
frame . onload = ( ) => {
// console.log('[studio] frame.onload fired, frame clientWidth:', frame.clientWidth,
// 'clientHeight:', frame.clientHeight);
// console.log('[studio] frame computed style width:',
// window.getComputedStyle(frame).width,
// 'height:', window.getComputedStyle(frame).height,
// 'display:', window.getComputedStyle(frame).display,
// 'position:', window.getComputedStyle(frame).position);
frame . _schemaReady = true ;
// console.log('[studio] about to postMessage, contentWindow:',
// !!frame.contentWindow,
// 'contentWindow.location:', frame.contentWindow?.location?.href);
frame . contentWindow . postMessage ( msg , '*' ) ;
} ;
frame . src = 'schema.html' ;
// console.log('[studio] frame clientWidth:', frame.clientWidth,
// 'clientHeight:', frame.clientHeight,
// 'offsetWidth:', frame.offsetWidth,
// 'offsetHeight:', frame.offsetHeight);
} else {
// Already loaded — send directly
frame . contentWindow . postMessage ( msg , '*' ) ;
}
2025-12-21 00:31:26 +02:00
}
}
function resetStickyEditor ( ) {
document . getElementById ( 'inp-id' ) . value = '' ;
document . getElementById ( 'inp-label' ) . value = '' ;
document . getElementById ( 'inp-value' ) . value = '' ;
document . getElementById ( 'inp-desc' ) . value = '' ;
document . getElementById ( 'inp-x' ) . value = '' ;
document . getElementById ( 'inp-y' ) . value = '' ;
document . getElementById ( 'inp-img-id' ) . value = '' ;
document . getElementById ( 'new-part-tool-container' ) . innerHTML = '' ;
document . getElementById ( 'inline-thumbs' ) . innerHTML = '' ;
2026-04-24 17:28:59 +03:00
if ( spyglass ) spyglass . clear ( ) ;
2025-12-21 00:31:26 +02:00
}
2026-04-24 17:28:59 +03:00
function parseLabel ( l ) { const m = l . toUpperCase ( ) . match ( /^([A-Z]+)(\d+)(.*)$/ ) ; return m ? { valid : true , prefix : m [ 1 ] , num : parseInt ( m [ 2 ] ) } : { valid : false } ; }
async function changeSortMode ( ) { sortMode = document . getElementById ( 'sort-select' ) . value ; const m = bomList . find ( x => x . id === currentBomId ) ; if ( m ) { m . sortMode = sortMode ; await db . addProject ( m ) ; renderList ( ) ; } }
2025-12-21 00:31:26 +02:00
function getSortedView ( ) {
let view = bomData . map ( ( item , index ) => ( { ... item , dataIndex : index } ) ) ;
if ( sortMode === 'none' ) return view ;
view . sort ( ( a , b ) => {
2026-04-24 17:28:59 +03:00
const pa = parseLabel ( a . label ) , pb = parseLabel ( b . label ) ;
2025-12-21 00:31:26 +02:00
if ( ! pa . valid || ! pb . valid ) return a . label . localeCompare ( b . label ) ;
2026-04-24 17:28:59 +03:00
if ( sortMode === 'std' ) return pa . prefix !== pb . prefix ? pa . prefix . localeCompare ( pb . prefix ) : pa . num - pb . num ;
return pa . num !== pb . num ? pa . num - pb . num : pa . prefix . localeCompare ( pb . prefix ) ;
2025-12-21 00:31:26 +02:00
} ) ;
return view ;
}
function renderList ( ) {
2026-04-24 17:28:59 +03:00
const tb = document . getElementById ( 'bom-body' ) ; tb . innerHTML = '' ;
2025-12-21 00:31:26 +02:00
let view = getSortedView ( ) ;
2026-04-24 17:28:59 +03:00
if ( view . length === 0 ) tb . innerHTML = ` <tr><td colspan="4" class="empty-state" style="padding:2rem">Empty BOM</td></tr> ` ;
2025-12-21 00:31:26 +02:00
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' ) ;
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
// [NEW] Assign ID for lookup
2025-12-21 18:45:40 +02:00
tr . dataset . id = p . id ;
2025-12-21 00:31:26 +02:00
tr . onclick = ( e ) => {
2026-04-24 17:28:59 +03:00
if ( e . target . tagName === 'BUTTON' || e . target . closest ( 'button' ) ) return ;
fillFormFromData ( p ) ;
document . querySelectorAll ( 'tbody tr' ) . forEach ( x => x . classList . remove ( 'editing' ) ) ;
tr . classList . add ( 'editing' ) ;
2025-12-21 00:31:26 +02:00
} ;
const tools = getToolButtonsHtml ( p . label , p . dataIndex ) ;
tr . innerHTML = ` <td><strong> ${ pinIcon } ${ p . label } </strong></td>
2026-04-24 17:28:59 +03:00
< td > < div class = "val-cell" > < span class = "val-text" > $ { p . value || '-' } < / s p a n > < d i v c l a s s = " t o o l - g r o u p " > $ { t o o l s } < b u t t o n c l a s s = " t o o l - b t n c o p y - b t n " o n c l i c k = " c o p y P a r t T o F o r m ( $ { p . d a t a I n d e x } ) " t i t l e = " C o p y " > 📄 < / b u t t o n > < / d i v > < / d i v > < / t d >
2025-12-21 00:31:26 +02:00
< td > $ { p . desc } < / t d > ` ;
tb . appendChild ( tr ) ;
} ) ;
// Check if new connections were made, update overlap cache if so (from modal)
2026-04-24 17:28:59 +03:00
if ( currentImgId ) updateOverlapCache ( ) . then ( ( ) => { } ) ;
2025-12-21 00:31:26 +02:00
}
function toggleExport ( e ) { e . stopPropagation ( ) ; const el = document . getElementById ( 'export-dropdown' ) ; const isShown = el . style . display === 'block' ; document . querySelectorAll ( '.dropdown-content' ) . forEach ( d => d . style . display = 'none' ) ; el . style . display = isShown ? 'none' : 'block' ; }
window . addEventListener ( 'click' , ( ) => document . querySelectorAll ( '.dropdown-content' ) . forEach ( d => d . style . display = 'none' ) ) ;
async function removePart ( idx ) {
2026-04-24 17:28:59 +03:00
if ( await confirmAction ( "Remove this component?" , "Remove" ) ) {
2025-12-21 00:31:26 +02:00
await db . deleteComponent ( bomData [ idx ] . id ) ;
await loadProjectData ( ) ;
}
}
async function clearCurrentBom ( ) {
2026-04-24 17:28:59 +03:00
if ( await confirmAction ( "Clear ALL components from this board?" , "Clear All" ) ) {
2025-12-21 00:31:26 +02:00
await db . clearComponents ( currentBomId ) ;
await loadProjectData ( ) ;
resetStickyEditor ( ) ;
}
}
async function deleteBom ( ) {
2026-04-24 17:28:59 +03:00
if ( bomList . length < 1 ) return ;
if ( await confirmAction ( "Delete this Board and all its content?" , "Delete Board" ) ) {
2025-12-21 00:31:26 +02:00
await db . deleteProject ( currentBomId ) ;
2025-12-22 18:45:13 +02:00
history . back ( ) ;
2025-12-21 00:31:26 +02:00
await loadDeviceBoms ( ) ;
resetStickyEditor ( ) ;
}
}
2026-04-24 17:28:59 +03:00
function checkToolHint ( v ) { document . getElementById ( 'new-part-tool-container' ) . innerHTML = getToolButtonsHtml ( v , 'new' ) ; }
2025-12-21 00:31:26 +02:00
function getToolButtonsHtml ( lbl , idx ) {
2026-04-24 17:28:59 +03:00
if ( ! lbl ) return '' ; const p = lbl . charAt ( 0 ) . toUpperCase ( ) , ts = TOOL _REGISTRY [ p ] ; if ( ! ts ) return '' ;
const iStr = ( idx === 'new' ) ? "'new'" : idx ; let h = '' ;
const cnt = ts . length > MAX _DIRECT _TOOLS ? MAX _DIRECT _TOOLS - 1 : ts . length ;
for ( let i = 0 ; i < cnt ; i ++ ) h += ` <button class="tool-btn" onclick="openToolD( ${ iStr } ,' ${ ts [ i ] . url } ',' ${ ts [ i ] . title } ')"> ${ ts [ i ] . icon } </button> ` ;
if ( ts . length > MAX _DIRECT _TOOLS ) h += ` <button class="tool-btn more-btn" onclick="openToolS( ${ iStr } )">•••</button> ` ;
2025-12-21 00:31:26 +02:00
return ` <div class="tool-group"> ${ h } </div> ` ;
}
// --- TOOLS ---
2026-04-24 17:28:59 +03:00
const modal = document . getElementById ( 'tool-modal' ) , iframe = document . getElementById ( 'tool-frame' ) , toolTitle = document . getElementById ( 'tool-title' ) ;
2025-12-21 00:31:26 +02:00
async function openToolD ( idx , url , tit ) {
editingIndex = idx ;
toolTitle . innerText = tit ;
document . getElementById ( 'tool-selector' ) . style . display = 'none' ;
iframe . style . display = 'none' ;
modal . style . display = 'flex' ;
let d = ( idx === 'new' )
? { v : document . getElementById ( 'inp-value' ) . value , d : document . getElementById ( 'inp-desc' ) . value , comp : null }
: { v : bomData [ idx ] . value , d : bomData [ idx ] . desc , comp : bomData [ idx ] } ;
let srcX = null , srcY = null , srcImgId = null ;
if ( idx !== 'new' && d . comp ) {
srcImgId = d . comp . imgId ; srcX = d . comp . x ; srcY = d . comp . y ;
} else if ( idx === 'new' ) {
const iid = document . getElementById ( 'inp-img-id' ) . value ;
if ( iid ) {
srcImgId = iid ;
srcX = parseFloat ( document . getElementById ( 'inp-x' ) . value ) ;
srcY = parseFloat ( document . getElementById ( 'inp-y' ) . value ) ;
}
}
const views = [ ] ;
const transferables = [ ] ;
if ( srcImgId && srcX !== null && srcY !== null ) {
try {
const imgRec = await db . getImage ( srcImgId ) ;
if ( imgRec && imgRec . blob ) {
const bmp = await createImageBitmap ( imgRec . blob ) ;
views . push ( { name : imgRec . name + " (Main)" , bitmap : bmp , x : srcX , y : srcY , isMain : true } ) ;
transferables . push ( bmp ) ;
// USE HELPER for Inferred Views
const paths = await ImageGraph . solvePaths ( srcImgId , cvManager , db ) ;
for ( const pData of paths ) {
const pt = cvManager . projectPoint ( srcX , srcY , pData . H ) ;
if ( pt ) {
const targetRec = await db . getImage ( pData . id ) ;
2026-04-24 17:28:59 +03:00
if ( targetRec ) {
2025-12-21 00:31:26 +02:00
const tBmp = await createImageBitmap ( targetRec . blob ) ;
if ( pt . x > 0 && pt . y > 0 && pt . x < tBmp . width && pt . y < tBmp . height ) {
views . push ( { name : targetRec . name , bitmap : tBmp , x : pt . x , y : pt . y , isMain : false , imgId : pData . id } ) ;
transferables . push ( tBmp ) ;
} else {
tBmp . close ( ) ;
}
}
}
}
}
} catch ( e ) { console . error ( "Error generating views:" , e ) ; }
}
const loadHandler = ( ) => {
iframe . style . display = 'block' ;
const msg = { type : 'INIT_TOOL' , value : d . v , description : d . d , views : views } ;
iframe . contentWindow . postMessage ( msg , '*' , transferables ) ;
} ;
iframe . removeEventListener ( 'load' , loadHandler ) ;
iframe . addEventListener ( 'load' , loadHandler , { once : true } ) ;
const finalUrl = url + "?embed=true" ;
// --- Use location.replace to avoid history pollution ---
if ( iframe . contentWindow && iframe . contentWindow . location ) {
try {
iframe . contentWindow . location . replace ( finalUrl ) ;
2026-04-24 17:28:59 +03:00
} catch ( e ) {
2025-12-21 00:31:26 +02:00
// Fallback for first load or cross-origin issues (though local)
iframe . src = finalUrl ;
}
} else {
iframe . src = finalUrl ;
}
}
function openToolS ( idx ) {
2026-04-24 17:28:59 +03:00
editingIndex = idx ; const lbl = ( idx === 'new' ) ? document . getElementById ( 'inp-label' ) . value : bomData [ idx ] . label ; const ts = TOOL _REGISTRY [ lbl . charAt ( 0 ) . toUpperCase ( ) ] ;
toolTitle . innerText = "Select Tool" ; iframe . style . display = 'none' ; document . getElementById ( 'tool-selector' ) . style . display = 'flex' ; modal . style . display = 'flex' ;
const g = document . getElementById ( 'selector-grid' ) ; g . innerHTML = '' ;
ts . forEach ( t => { const c = document . createElement ( 'div' ) ; c . className = 'tool-card' ; c . innerHTML = ` <div class="tool-card-icon"> ${ t . icon } </div><div class="tool-card-title"> ${ t . title } </div> ` ; c . onclick = ( ) => openToolD ( idx , t . url , t . title ) ; g . appendChild ( c ) ; } ) ;
}
function closeTool ( ) { modal . style . display = 'none' ; iframe . src = '' ; }
window . addEventListener ( 'message' , async e => {
if ( e . data . type === 'COMPONENT_UPDATE' ) {
if ( editingIndex === 'new' ) { document . getElementById ( 'inp-value' ) . value = e . data . value ; if ( e . data . description ) document . getElementById ( 'inp-desc' ) . value = e . data . description ; }
else { const c = bomData [ editingIndex ] ; c . value = e . data . value ; if ( e . data . description ) c . desc = e . data . description ; await db . addComponent ( c ) ; await loadProjectData ( ) ; }
2025-12-21 00:31:26 +02:00
closeTool ( ) ;
}
// 2. Handle Promotion (Swap Source Image)
else if ( e . data . type === 'PROMOTE_VIEW' ) {
if ( editingIndex !== - 1 && editingIndex !== 'new' ) {
const c = bomData [ editingIndex ] ;
c . imgId = e . data . imgId ; // Switch the source image ID
c . x = e . data . x ;
c . y = e . data . y ;
await db . addComponent ( c ) ;
await loadProjectData ( ) ; // Refresh Map/List
closeTool ( ) ; // Close tool to force refresh of state
}
}
// 3. Handle Position Adjustment (Drag in Spyglass)
else if ( e . data . type === 'UPDATE_POS' ) {
if ( editingIndex !== - 1 && editingIndex !== 'new' ) {
const c = bomData [ editingIndex ] ;
c . x = e . data . x ;
c . y = e . data . y ;
await db . addComponent ( c ) ;
// Note: We don't reloadProjectData() here to keep the UI smooth while dragging
}
}
2026-04-24 17:28:59 +03:00
else if ( e . data ? . type === 'SCHEMA_READY' ) {
const frame = document . getElementById ( 'schema-frame' ) ;
if ( frame ) clearInterval ( frame . _schemaTimer ) ;
}
2025-12-21 00:31:26 +02:00
} ) ;
// --- SPYGLASS & MAP (ENHANCED) ---
async function showImage ( id ) {
currentImgId = id ;
const imgSel = document . getElementById ( 'image-select' ) ;
2026-04-24 17:28:59 +03:00
if ( imgSel && imgSel . value !== id ) imgSel . value = id ;
2025-12-21 00:31:26 +02:00
const imgObj = bomImages . find ( i => i . id === id ) ;
2026-04-24 17:28:59 +03:00
if ( ! imgObj ) return ;
2025-12-21 00:31:26 +02:00
const url = URL . createObjectURL ( imgObj . blob ) ;
const currentImg = document . getElementById ( 'map-content' ) . querySelector ( '.pcb-image' ) ;
2026-04-24 17:28:59 +03:00
if ( ! currentImg || currentImg . dataset . id !== id ) {
document . getElementById ( 'map-content' ) . innerHTML = ` <img src=" ${ url } " data-id=" ${ id } " class="pcb-image"> ` ;
const newImg = document . getElementById ( 'map-content' ) . querySelector ( '.pcb-image' ) ;
newImg . onload = ( ) => fitMap ( ) ;
2025-12-21 00:31:26 +02:00
}
await updateOverlapCache ( ) ; // LOAD OVERLAPS
renderPins ( ) ;
}
function fitMap ( ) {
2026-04-24 17:28:59 +03:00
if ( skipNextFit ) { skipNextFit = false ; return ; }
2025-12-21 00:31:26 +02:00
const img = document . getElementById ( 'map-content' ) . querySelector ( '.pcb-image' ) ;
2026-04-24 17:28:59 +03:00
if ( ! img ) return ;
if ( img . naturalWidth === 0 ) { img . onload = ( ) => fitMap ( ) ; return ; }
2025-12-21 00:31:26 +02:00
const viewport = document . getElementById ( 'map-viewport' ) ;
const vw = viewport . clientWidth ; const vh = viewport . clientHeight ;
if ( vw === 0 || vh === 0 ) return ;
2026-04-24 17:28:59 +03:00
const scale = Math . min ( vw / img . naturalWidth , vh / img . naturalHeight ) ;
mapState = { scale : scale , x : ( vw - img . naturalWidth * scale ) / 2 , y : ( vh - img . naturalHeight * scale ) / 2 , isDragging : false } ;
2025-12-21 00:31:26 +02:00
updateTransform ( ) ;
}
function switchImage ( ) { showImage ( document . getElementById ( 'image-select' ) . value ) ; setTimeout ( fitMap , 50 ) ; }
async function locateComponent ( imgId , x , y ) {
if ( ! imgId || x === undefined || y === undefined ) return ;
// 1. Switch to Map Tab
switchView ( 'map' ) ;
// 2. Load Image (Prevent fitMap from resetting our zoom)
if ( currentImgId !== imgId ) {
skipNextFit = true ;
await showImage ( imgId ) ;
}
// 3. Calculate Center & Zoom
const viewport = document . getElementById ( 'map-viewport' ) ;
const vw = viewport . clientWidth || window . innerWidth ;
const vh = viewport . clientHeight || window . innerHeight ;
const targetScale = 4 ; // Max/High Zoom
mapState = {
scale : targetScale ,
x : ( vw / 2 ) - ( x * targetScale ) ,
y : ( vh / 2 ) - ( y * targetScale ) ,
2026-04-24 17:28:59 +03:00
isDragging : false , startX : 0 , startY : 0
2025-12-21 00:31:26 +02:00
} ;
updateTransform ( ) ;
renderPins ( ) ;
}
/ * *
* Saves a pre - processed image blob ( from the Import Modal ) to the database .
* This bypasses the file picker and allows for standardized names / types .
* /
async function saveProcessedImageToDB ( blob , name ) {
if ( ! currentBomId ) { alert ( "Please select a Board first." ) ; return ; }
const id = uuid ( ) ;
const newImage = { id : id , boardId : currentBomId , name : name , blob : blob , created : Date . now ( ) } ;
try {
await db . addImage ( newImage ) ;
await loadProjectData ( ) ;
const imgSelect = document . getElementById ( 'image-select' ) ;
2026-04-24 17:28:59 +03:00
if ( imgSelect ) { imgSelect . value = id ; switchImage ( ) ; }
2025-12-21 00:31:26 +02:00
switchView ( 'map' ) ;
await autoStitchNewImage ( id ) ;
} catch ( e ) {
console . error ( "Failed to save image:" , e ) ;
alert ( "Error saving image to database." ) ;
}
}
// EXPOSE TO GLOBAL SCOPE so the HTML modal can call it
window . saveProcessedImageToDB = saveProcessedImageToDB ;
async function uploadImage ( input ) {
2026-04-24 17:28:59 +03:00
const file = input . files [ 0 ] ; if ( ! file ) return ;
2025-12-21 18:45:40 +02:00
const name = await requestInput ( "Upload Image" , "Image Name" , file . name ) ;
2026-04-24 17:28:59 +03:00
if ( ! name ) return ;
2025-12-21 00:31:26 +02:00
const id = uuid ( ) ; // Capture ID to use later
await db . addImage ( { id : id , boardId : currentBomId , blob : file , name : name } ) ;
await loadProjectData ( ) ;
switchView ( 'map' ) ;
await autoStitchNewImage ( id ) ;
}
// Open the Image Settings Modal
function openImageSettings ( ) {
2026-04-24 17:28:59 +03:00
if ( ! currentImgId ) return alert ( "No image selected" ) ;
2025-12-21 00:31:26 +02:00
const img = bomImages . find ( i => i . id === currentImgId ) ;
2026-04-24 17:28:59 +03:00
if ( ! img ) return ;
2025-12-21 00:31:26 +02:00
document . getElementById ( 'edit-image-name' ) . value = img . name ;
document . getElementById ( 'image-settings-modal' ) . style . display = 'flex' ;
}
// Save Rename Changes
async function saveImageSettings ( ) {
2026-04-24 17:28:59 +03:00
if ( ! currentImgId ) return ;
2025-12-21 00:31:26 +02:00
const newName = document . getElementById ( 'edit-image-name' ) . value . trim ( ) ;
2026-04-24 17:28:59 +03:00
if ( ! newName ) return alert ( "Name cannot be empty" ) ;
2025-12-21 00:31:26 +02:00
const img = bomImages . find ( i => i . id === currentImgId ) ;
// Only update DB if changed
2026-04-24 17:28:59 +03:00
if ( img . name !== newName ) {
2025-12-21 00:31:26 +02:00
img . name = newName ;
await db . addImage ( img ) ; // Upsert to DB
// Update UI Dropdown immediately without full reload
const sel = document . getElementById ( 'image-select' ) ;
const opt = sel . querySelector ( ` option[value=" ${ currentImgId } "] ` ) ;
2026-04-24 17:28:59 +03:00
if ( opt ) opt . innerText = newName ;
2025-12-21 00:31:26 +02:00
// Update connections modal source name if it's open or cached
const connName = document . getElementById ( 'conn-src-name' ) ;
2026-04-24 17:28:59 +03:00
if ( connName ) connName . innerText = newName ;
2025-12-21 00:31:26 +02:00
}
2025-12-22 18:45:13 +02:00
history . back ( ) ;
2025-12-21 00:31:26 +02:00
}
// Delete Image (Close modal on success)
async function deleteCurrentImage ( ) {
2026-04-24 17:28:59 +03:00
if ( ! currentImgId ) return ;
2025-12-21 00:31:26 +02:00
const usageCount = bomData . filter ( c => c . imgId === currentImgId ) . length ;
if ( usageCount > 0 ) {
alert ( ` Cannot delete this image. \n \n It is currently defined as the Main View for ${ usageCount } component(s). \n Please reassign or delete these components first. ` ) ;
return ;
}
// Note: confirmAction uses z-index 1100, so it appears over settings
2026-04-24 17:28:59 +03:00
if ( await confirmAction ( "Delete current image?" , "Delete Image" ) ) {
2025-12-21 00:31:26 +02:00
await db . deleteImage ( currentImgId ) ;
// Remove from UI arrays
bomImages = bomImages . filter ( i => i . id !== currentImgId ) ;
// Close the settings modal if open
2025-12-22 18:45:13 +02:00
history . back ( ) ;
2025-12-21 00:31:26 +02:00
currentImgId = null ;
await loadProjectData ( ) ;
resetStickyEditor ( ) ;
}
}
function clearMap ( ) { document . getElementById ( 'map-content' ) . innerHTML = ` <div id="map-placeholder" class="empty-state" style="color:white; padding-top:20vh;"><h3>No Image</h3></div> ` ; }
async function updateOverlapCache ( ) {
if ( ! currentImgId ) { currentOverlaps = [ ] ; return ; }
const connections = await getConnectedImages ( currentImgId ) ;
currentOverlaps = connections . map ( conn => {
// Use the clean helper method within ImageGraph
const invMat = ImageGraph . invertH ( conn . matrix ) ;
return {
fromImageId : conn . sourceId ,
toImageId : currentImgId ,
homography : invMat ,
inverseHomography : conn . matrix ,
isValid : ! ! invMat
} ;
} ) . filter ( o => o . isValid ) ;
}
function renderPins ( ) {
document . querySelectorAll ( '.map-pin' ) . forEach ( p => p . remove ( ) ) ;
if ( ! currentImgId ) return ;
bomData . forEach ( c => {
let x = null , y = null ;
let isInferred = false ;
// A: Defined on this image (Blue)
if ( c . imgId === currentImgId && c . x !== undefined ) {
x = c . x ; y = c . y ;
}
// B: Inferred from stitching (Green)
else if ( c . imgId && c . imgId !== currentImgId && c . x !== undefined ) {
const ov = currentOverlaps . find ( o => o . fromImageId === c . imgId || o . toImageId === c . imgId ) ;
if ( ov ) {
const isForward = ( ov . fromImageId === c . imgId ) ;
const H = isForward ? ov . homography : ov . inverseHomography ;
const proj = cvManager . projectPoint ( c . x , c . y , H ) ;
if ( proj && proj . x > 0 && proj . y > 0 ) {
2026-04-24 17:28:59 +03:00
x = proj . x ; y = proj . y ; isInferred = true ;
2025-12-21 00:31:26 +02:00
}
}
}
if ( x !== null && y !== null ) {
const pin = document . createElement ( 'div' ) ;
pin . className = 'map-pin' ;
pin . style . left = x + 'px' ;
pin . style . top = y + 'px' ;
const color = isInferred ? '#16a34a' : 'var(--primary)' ;
const zIndex = isInferred ? 40 : 50 ;
// Both Green and Blue pins now open the Editor
const clickAction = ` editCompFromMap(' ${ c . id } ', event) ` ;
pin . innerHTML = ` <div class="pin-marker" style="background: ${ color } ; z-index: ${ zIndex } ;" onclick=" ${ clickAction } ; event.stopPropagation();"><span> ${ c . label } </span></div> ` ;
document . getElementById ( 'map-content' ) . appendChild ( pin ) ;
}
} ) ;
}
function updateTransform ( ) { document . getElementById ( 'map-content' ) . style . transform = ` translate( ${ mapState . x } px, ${ mapState . y } px) scale( ${ mapState . scale } ) ` ; }
const viewport = document . getElementById ( 'map-viewport' ) ;
viewport . addEventListener ( 'wheel' , e => {
e . preventDefault ( ) ;
const rect = viewport . getBoundingClientRect ( ) ;
const mx = e . clientX - rect . left ; const my = e . clientY - rect . top ;
const ix = ( mx - mapState . x ) / mapState . scale ; const iy = ( my - mapState . y ) / mapState . scale ;
const delta = e . deltaY > 0 ? - 0.1 : 0.1 ;
const newScale = Math . max ( 0.1 , Math . min ( 10 , mapState . scale + delta ) ) ;
mapState . x = mx - ( ix * newScale ) ; mapState . y = my - ( iy * newScale ) ; mapState . scale = newScale ;
updateTransform ( ) ;
} ) ;
/* --- POINTER EVENTS (Mobile + Desktop) --- */
let evCache = [ ] ;
let prevDiff = - 1 ;
function pointerDownHandler ( e ) {
2026-04-24 17:28:59 +03:00
if ( e . target . closest ( '.pin-marker' ) ) return ;
2025-12-21 00:31:26 +02:00
evCache . push ( e ) ;
// 1. Single Pointer (Mouse or One Finger) -> Start Drag
if ( evCache . length === 1 ) {
mapState . isDragging = true ;
mapState . startX = e . clientX - mapState . x ;
mapState . startY = e . clientY - mapState . y ;
mapState . rawStartX = e . clientX ;
mapState . rawStartY = e . clientY ;
// Listen globally while dragging (Fixes "fast drag" issue on Desktop)
window . addEventListener ( 'pointermove' , pointerMoveHandler ) ;
window . addEventListener ( 'pointerup' , pointerUpHandler ) ;
}
// 2. Two Pointers (Pinch) -> Switch to Zoom
else if ( evCache . length === 2 ) {
mapState . isDragging = false ; // Disable pan during pinch
const dx = evCache [ 0 ] . clientX - evCache [ 1 ] . clientX ;
const dy = evCache [ 0 ] . clientY - evCache [ 1 ] . clientY ;
prevDiff = Math . hypot ( dx , dy ) ;
}
}
function pointerMoveHandler ( e ) {
// Update event in cache (for Pinch calc)
const index = evCache . findIndex ( cached => cached . pointerId === e . pointerId ) ;
2026-04-24 17:28:59 +03:00
if ( index > - 1 ) evCache [ index ] = e ;
2025-12-21 00:31:26 +02:00
// A. PINCH ZOOM (Mobile)
if ( evCache . length === 2 ) {
const dx = evCache [ 0 ] . clientX - evCache [ 1 ] . clientX ;
const dy = evCache [ 0 ] . clientY - evCache [ 1 ] . clientY ;
const curDiff = Math . hypot ( dx , dy ) ;
if ( prevDiff > 0 ) {
const diff = curDiff - prevDiff ;
const newScale = Math . max ( 0.1 , Math . min ( 10 , mapState . scale + ( diff * 0.01 ) ) ) ;
// Zoom towards center
const rect = viewport . getBoundingClientRect ( ) ;
const cx = rect . width / 2 ;
const cy = rect . height / 2 ;
const ratio = newScale / mapState . scale ;
mapState . x = cx - ( cx - mapState . x ) * ratio ;
mapState . y = cy - ( cy - mapState . y ) * ratio ;
mapState . scale = newScale ;
updateTransform ( ) ;
}
prevDiff = curDiff ;
return ;
}
// B. PANNING (Desktop + Mobile)
2026-04-24 17:28:59 +03:00
if ( ! mapState . isDragging ) return ;
2025-12-21 00:31:26 +02:00
// e.preventDefault() helps stop scrolling on mobile,
// but we check pointerType to avoid selecting text issues on desktop
2026-04-24 17:28:59 +03:00
if ( e . pointerType === 'touch' ) e . preventDefault ( ) ;
2025-12-21 00:31:26 +02:00
mapState . x = e . clientX - mapState . startX ;
mapState . y = e . clientY - mapState . startY ;
updateTransform ( ) ;
}
function pointerUpHandler ( e ) {
const index = evCache . findIndex ( cached => cached . pointerId === e . pointerId ) ;
2026-04-24 17:28:59 +03:00
if ( index > - 1 ) evCache . splice ( index , 1 ) ;
2025-12-21 00:31:26 +02:00
if ( evCache . length < 2 ) prevDiff = - 1 ;
if ( evCache . length === 0 ) {
mapState . isDragging = false ;
// Clean up global listeners
window . removeEventListener ( 'pointermove' , pointerMoveHandler ) ;
window . removeEventListener ( 'pointerup' , pointerUpHandler ) ;
}
}
// Only 'pointerdown' needs to be on the viewport
viewport . addEventListener ( 'pointerdown' , pointerDownHandler ) ;
// Click Logic (Distinguishes Drag vs Click)
viewport . addEventListener ( 'click' , e => {
const dist = Math . hypot ( e . clientX - mapState . rawStartX , e . clientY - mapState . rawStartY ) ;
2026-04-24 17:28:59 +03:00
if ( dist > 10 ) return ; // Ignore if dragged
2025-12-21 00:31:26 +02:00
2026-04-24 17:28:59 +03:00
if ( mapState . isDragging || ! currentImgId || e . target . closest ( '.pin-marker' ) ) return ;
2025-12-21 00:31:26 +02:00
const r = document . getElementById ( 'map-content' ) . getBoundingClientRect ( ) ;
const x = ( e . clientX - r . left ) / mapState . scale ;
const y = ( e . clientY - r . top ) / mapState . scale ;
switchView ( 'list' ) ;
// 1. Deselect any active row in the table (Visual cleanup)
document . querySelectorAll ( 'tbody tr' ) . forEach ( row => row . classList . remove ( 'editing' ) ) ;
// 2. Create a temporary "New Component" object
const tempComp = {
label : '' ,
value : '' ,
desc : '' ,
imgId : currentImgId ,
x : Math . round ( x ) ,
y : Math . round ( y )
} ;
// 3. Use the standard function to setup UI (Inputs + Spyglass + THUMBNAILS)
fillFormFromData ( tempComp ) ;
// 4. Focus for immediate typing
document . getElementById ( 'inp-label' ) . focus ( ) ;
returnToMap = true ;
} ) ;
async function editCompFromMap ( id , e ) {
e . stopPropagation ( ) ;
const comp = bomData . find ( c => c . id === id ) ;
2026-04-24 17:28:59 +03:00
if ( ! comp ) return ;
2025-12-21 00:31:26 +02:00
fillFormFromData ( comp ) ;
returnToMap = true ;
switchView ( 'list' ) ;
// [NEW] Synchronize List Selection
setTimeout ( ( ) => {
const row = document . querySelector ( ` #bom-body tr[data-id=" ${ id } "] ` ) ;
2026-04-24 17:28:59 +03:00
if ( row ) {
2025-12-21 00:31:26 +02:00
// Clear existing
document . querySelectorAll ( 'tbody tr' ) . forEach ( x => x . classList . remove ( 'editing' ) ) ;
// Highlight new
row . classList . add ( 'editing' ) ;
// Scroll into view
row . scrollIntoView ( { behavior : 'smooth' , block : 'center' } ) ;
}
} , 50 ) ; // Small delay to ensure view transition allows scrolling
}
async function fillFormFromData ( c ) {
// Reset state: Default view is always Main
isMainViewActive = true ;
// 1. Fill Text Fields
document . getElementById ( 'inp-id' ) . value = c . id || '' ;
document . getElementById ( 'inp-label' ) . value = c . label ;
document . getElementById ( 'inp-value' ) . value = c . value ;
document . getElementById ( 'inp-desc' ) . value = c . desc ;
document . getElementById ( 'inp-x' ) . value = ( c . x !== undefined ) ? c . x : '' ;
document . getElementById ( 'inp-y' ) . value = ( c . y !== undefined ) ? c . y : '' ;
document . getElementById ( 'inp-img-id' ) . value = c . imgId || '' ;
checkToolHint ( c . label ) ;
// 2. Clear previous thumbnails
const thumbBox = document . getElementById ( 'inline-thumbs' ) ;
2026-04-24 17:28:59 +03:00
if ( thumbBox ) thumbBox . innerHTML = '' ;
2025-12-21 00:31:26 +02:00
// --- HELPER: Set Spyglass Target ---
const setMainView = ( imgRecOrSource , x , y ) => {
2026-04-24 17:28:59 +03:00
if ( ! spyglass ) return ;
if ( imgRecOrSource instanceof HTMLElement || imgRecOrSource instanceof ImageBitmap ) {
2025-12-21 00:31:26 +02:00
spyglass . setTarget ( imgRecOrSource , x , y , false ) ;
return ;
}
2026-04-24 17:28:59 +03:00
if ( imgRecOrSource && imgRecOrSource . blob ) {
2025-12-21 00:31:26 +02:00
createImageBitmap ( imgRecOrSource . blob ) . then ( bmp => {
spyglass . setTarget ( bmp , x , y , true ) ;
} ) ;
}
} ;
// --- HELPER: Create Thumbnail ---
const createThumb = ( imgRec , tx , ty , isMain ) => {
const thumb = document . createElement ( 'div' ) ;
thumb . style . cssText = "width:60px; height:60px; border:1px solid #cbd5e1; flex-shrink:0; cursor:pointer; position:relative; background:#eee; display:flex; align-items:center; justify-content:center;" ;
2026-04-24 17:28:59 +03:00
if ( isMain ) thumb . style . borderColor = "#2563eb" ;
2025-12-21 00:31:26 +02:00
// Loader placeholder
thumb . innerHTML = '<span style="font-size:10px; color:#999;">...</span>' ;
thumbBox . appendChild ( thumb ) ;
( async ( ) => {
try {
const bmp = await createImageBitmap ( imgRec . blob ) ;
// 1. Clear loader (and anything else)
thumb . innerHTML = '' ;
// 2. Add Canvas
const cvs = document . createElement ( 'canvas' ) ;
cvs . width = 60 ; cvs . height = 60 ;
const ctx = cvs . getContext ( '2d' ) ;
2026-04-24 17:28:59 +03:00
ctx . drawImage ( bmp , tx - 50 , ty - 50 , 100 , 100 , 0 , 0 , 60 , 60 ) ;
2025-12-21 00:31:26 +02:00
thumb . appendChild ( cvs ) ;
thumb . title = isMain ? "Main View" : ` View on ${ imgRec . name } ` ;
bmp . close ( ) ;
// 3. Add Locate Button 🎯 (Now safe from being cleared)
const locBtn = document . createElement ( 'button' ) ;
locBtn . className = 'thumb-locate-btn' ;
locBtn . innerHTML = '🎯' ;
locBtn . title = "Locate on Map" ;
locBtn . onclick = ( e ) => { e . stopPropagation ( ) ; locateComponent ( imgRec . id , tx , ty ) ; } ;
thumb . appendChild ( locBtn ) ;
// 4. Add Promote Button ★
if ( ! isMain ) {
const btn = document . createElement ( 'button' ) ;
btn . className = 'thumb-promote-btn' ;
btn . innerHTML = '★' ;
btn . title = "Set as Main View" ;
btn . onclick = async ( e ) => {
e . stopPropagation ( ) ;
// Capture form state
c . id = document . getElementById ( 'inp-id' ) . value ;
c . label = document . getElementById ( 'inp-label' ) . value ;
c . value = document . getElementById ( 'inp-value' ) . value ;
c . desc = document . getElementById ( 'inp-desc' ) . value ;
// Update component location
c . imgId = imgRec . id ; c . x = tx ; c . y = ty ;
// Update DB if exists
2026-04-24 17:28:59 +03:00
if ( c . id ) { await db . addComponent ( c ) ; await loadProjectData ( ) ; }
2025-12-21 00:31:26 +02:00
// Refresh UI
fillFormFromData ( c ) ;
} ;
thumb . appendChild ( btn ) ;
}
// Thumb Click Action
thumb . onclick = ( ) => {
Array . from ( thumbBox . children ) . forEach ( t => t . style . borderColor = '#cbd5e1' ) ;
thumb . style . borderColor = "#2563eb" ;
isMainViewActive = isMain ;
setMainView ( imgRec , tx , ty ) ;
} ;
2026-04-24 17:28:59 +03:00
} catch ( e ) {
2025-12-21 00:31:26 +02:00
console . error ( "Thumb error" , e ) ;
thumb . innerHTML = '<span style="color:red">x</span>' ;
}
} ) ( ) ;
} ;
// 3. Calculate Views
const viewsToRender = [ ] ;
let mainImgRec = null ;
2026-04-24 17:28:59 +03:00
if ( ( c . x !== undefined ) && ( c . y !== undefined ) && c . imgId ) {
2025-12-21 00:31:26 +02:00
mainImgRec = bomImages . find ( i => i . id === c . imgId ) ;
2026-04-24 17:28:59 +03:00
if ( mainImgRec ) {
2025-12-21 00:31:26 +02:00
viewsToRender . push ( { rec : mainImgRec , x : c . x , y : c . y , isMain : true } ) ;
// Initial Load: Check if Map is already showing this
const mapImg = document . getElementById ( 'map-content' ) ? document . getElementById ( 'map-content' ) . querySelector ( '.pcb-image' ) : null ;
2026-04-24 17:28:59 +03:00
if ( mapImg && mapImg . dataset . id === c . imgId && mapImg . complete && mapImg . naturalWidth > 0 && spyglass ) {
2025-12-21 00:31:26 +02:00
spyglass . setTarget ( mapImg , c . x , c . y , false ) ;
} else {
setMainView ( mainImgRec , c . x , c . y ) ;
}
} else if ( spyglass ) { spyglass . clear ( ) ; }
}
if ( cvManager && db && c . imgId ) {
const paths = await ImageGraph . solvePaths ( c . imgId , cvManager , db ) ;
for ( const pData of paths ) {
const H _new = pData . H ;
const pt = cvManager . projectPoint ( c . x , c . y , H _new ) ;
if ( pt && pt . x > 0 && pt . y > 0 ) {
const targetRec = bomImages . find ( i => i . id === pData . id ) ;
if ( targetRec ) {
// Check bounds if bitmap is available, or trust projectPoint roughly
// Usually we need the bitmap dimensions to be 100% sure,
// but rendering it via createThumb handles out-of-bounds gracefully (crops/blank)
viewsToRender . push ( { rec : targetRec , x : pt . x , y : pt . y , isMain : false } ) ;
}
}
}
}
// 4. Render All
viewsToRender . forEach ( v => createThumb ( v . rec , v . x , v . y , v . isMain ) ) ;
}
function copyPartToForm ( idx ) { const c = bomData [ idx ] ; document . getElementById ( 'inp-value' ) . value = c . value ; document . getElementById ( 'inp-desc' ) . value = c . desc ; }
// --- CONNECTIONS UI ---
2025-12-21 18:45:40 +02:00
// Delete connection function
2025-12-21 00:31:26 +02:00
async function deleteConnection ( targetId ) {
2026-04-24 17:28:59 +03:00
if ( ! currentImgId || ! targetId ) return ;
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
const targetImg = bomImages . find ( i => i . id === targetId ) ;
const name = targetImg ? targetImg . name : "this image" ;
2026-04-24 17:28:59 +03:00
if ( await confirmAction ( ` Remove stitching connection with ${ name } ? ` , "Remove Stitch" ) ) {
2025-12-21 00:31:26 +02:00
await db . deleteOverlapsForPair ( currentImgId , targetId ) ;
await renderConnectionsList ( ) ;
await updateOverlapCache ( ) ;
renderPins ( ) ;
}
}
// Note: Ensure this function is called by openConnectionsModal() in your existing code
async function renderConnectionsList ( ) {
2026-04-24 17:28:59 +03:00
if ( ! currentImgId ) return ;
2025-12-21 00:31:26 +02:00
const curImg = bomImages . find ( i => i . id === currentImgId ) ;
2026-04-24 17:28:59 +03:00
if ( ! curImg ) return ;
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
const title = document . getElementById ( 'conn-src-name' ) ;
2026-04-24 17:28:59 +03:00
if ( title ) title . innerText = curImg . name ;
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
const list = document . getElementById ( 'conn-list' ) ;
2026-04-24 17:28:59 +03:00
if ( ! list ) return ;
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
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 ) ;
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
list . innerHTML = '' ;
2026-04-24 17:28:59 +03:00
if ( others . length === 0 ) {
2025-12-21 18:45:40 +02:00
list . innerHTML = '<div style="padding:1rem; text-align:center; background:#eee; border-radius:4px;">No other images to stitch with.</div>' ;
return ;
2025-12-21 00:31:26 +02:00
}
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
others . forEach ( img => {
const ov = overlaps . find ( o => o . fromImageId === img . id || o . toImageId === img . id ) ;
const row = document . createElement ( 'div' ) ;
row . style . cssText = "display:flex; justify-content:space-between; align-items:center; padding:0.5rem; border:1px solid #e2e8f0; border-radius:0.3rem; background:white;" ;
let statusBadge = ` <span style="font-size:0.75rem; background:#f1f5f9; color:#94a3b8; padding:2px 6px; border-radius:4px;">Not Connected</span> ` ;
let actions = ` <button class="secondary sm-btn" onclick="launchStitch(' ${ img . id } ')">Edit Stitch</button> ` ;
2026-04-24 17:28:59 +03:00
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> ` ;
2025-12-21 00:31:26 +02:00
else statusBadge = ` <span style="font-size:0.75rem; background:#e0f2fe; color:#0369a1; padding:2px 6px; border-radius:4px;">Auto-CV</span> ` ;
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
// 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" > 🗑 ️ < / b u t t o n >
< button class = "secondary sm-btn" onclick = "launchStitch('${img.id}')" > Edit Stitch < / b u t t o n >
< / d i v > ` ;
}
row . innerHTML = ` <div><div style="font-weight:600; font-size:0.9rem;"> ${ img . name } </div> ${ statusBadge } </div> ${ actions } ` ;
list . appendChild ( row ) ;
} ) ;
}
// Uses the helper above
async function openConnectionsModal ( ) {
2026-04-24 17:28:59 +03:00
if ( ! currentImgId ) return alert ( "Select an image first." ) ;
2025-12-21 00:31:26 +02:00
document . getElementById ( 'connections-modal' ) . style . display = 'flex' ;
await renderConnectionsList ( ) ;
}
function launchStitch ( targetId ) {
document . getElementById ( 'connections-modal' ) . style . display = 'none' ;
2026-04-24 17:28:59 +03:00
if ( stitchEditor ) stitchEditor . open ( currentImgId , targetId ) ;
2025-12-21 00:31:26 +02:00
else alert ( "Editor not initialized." ) ;
}
async function runAutoCVBatch ( ) {
2026-04-24 17:28:59 +03:00
if ( ! cvManager || ! cvManager . ready ) { cvManager . init ( ) ; return alert ( "CV initializing... wait." ) ; }
if ( ! await confirmAction ( "Run Auto-CV on all unmatched images?" , "Run Auto-CV" ) ) return ;
2025-12-21 00:31:26 +02:00
// Capture ID locally to prevent errors if user switches image during processing
const sourceId = currentImgId ;
2026-04-24 17:28:59 +03:00
if ( ! sourceId ) return ;
2025-12-21 00:31:26 +02:00
document . getElementById ( 'conn-list' ) . innerHTML = '<div style="text-align:center; padding:2rem;">Processing...</div>' ;
// Only filter out self; bomImages is already scoped to project
const others = bomImages . filter ( i => i . id !== sourceId ) ;
const sourceBlob = bomImages . find ( i => i . id === sourceId ) . blob ;
2026-04-24 17:28:59 +03:00
for ( const other of others ) {
2025-12-21 00:31:26 +02:00
const existing = await db . getOverlapsForPair ( sourceId , other . id ) ;
2026-04-24 17:28:59 +03:00
if ( existing && existing . isManual ) continue ;
2025-12-21 00:31:26 +02:00
const blob1 = sourceBlob ;
const blob2 = other . blob ;
const f1 = await cvManager . feats ( blob1 ) ;
const f2 = await cvManager . feats ( blob2 ) ;
2026-04-24 17:28:59 +03:00
if ( f1 && f2 ) {
2025-12-21 00:31:26 +02:00
const res = cvManager . findH ( f1 , f2 ) ;
2026-04-24 17:28:59 +03:00
if ( res ) {
2025-12-21 00:31:26 +02:00
await db . deleteOverlapsForPair ( sourceId , other . id ) ;
await db . addOverlap ( {
id : uuid ( ) ,
fromImageId : sourceId ,
toImageId : other . id ,
homography : res . hData ,
inverseHomography : res . invHData ,
matchCount : res . matches ,
isManual : false
} ) ;
}
}
2026-04-24 17:28:59 +03:00
if ( f1 ) { f1 . kp . delete ( ) ; f1 . des . delete ( ) ; }
if ( f2 ) { f2 . kp . delete ( ) ; f2 . des . delete ( ) ; }
2025-12-21 00:31:26 +02:00
}
await renderConnectionsList ( ) ;
updateOverlapCache ( ) . then ( renderPins ) ;
}
// UI Helper: Shows a global blocking overlay with a message
function showBusy ( msg ) {
let el = document . getElementById ( 'busy-indicator' ) ;
2026-04-24 17:28:59 +03:00
if ( ! el ) {
2025-12-21 00:31:26 +02:00
el = document . createElement ( 'div' ) ;
el . id = 'busy-indicator' ;
el . style . cssText = "position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); background:rgba(0,0,0,0.8); color:white; padding:20px; border-radius:8px; z-index:9999; font-weight:bold; display:flex; align-items:center; gap:10px; box-shadow: 0 4px 15px rgba(0,0,0,0.3);" ;
document . body . appendChild ( el ) ;
}
el . innerHTML = ` <span style="animation:spin 1s linear infinite; display:inline-block">⏳</span> ${ msg } ` ;
el . style . display = 'flex' ;
}
function hideBusy ( ) {
const el = document . getElementById ( 'busy-indicator' ) ;
2026-04-24 17:28:59 +03:00
if ( el ) el . style . display = 'none' ;
2025-12-21 00:31:26 +02:00
}
// Logic: Auto-stitch specific image against all others on the board
async function autoStitchNewImage ( newId ) {
2026-04-24 17:28:59 +03:00
if ( ! cvManager ) cvManager = new CVManager ( ) ;
if ( ! cvManager . ready ) {
2025-12-21 00:31:26 +02:00
showBusy ( "Initializing Computer Vision..." ) ;
await cvManager . init ( ) ;
}
// Find the newly added image object
const newImg = bomImages . find ( i => i . id === newId ) ;
2026-04-24 17:28:59 +03:00
if ( ! newImg ) return ;
2025-12-21 00:31:26 +02:00
// Filter existing images (exclude the new one)
const others = bomImages . filter ( i => i . id !== newId ) ;
2026-04-24 17:28:59 +03:00
if ( others . length === 0 ) return ;
2025-12-21 00:31:26 +02:00
try {
showBusy ( "Extracting features from new image..." ) ;
// 1. Compute features for the NEW image ONCE
const fNew = await cvManager . feats ( newImg . blob ) ;
2026-04-24 17:28:59 +03:00
if ( ! fNew ) { hideBusy ( ) ; return ; }
2025-12-21 00:31:26 +02:00
let matchesFound = 0 ;
// 2. Compare against all others
2026-04-24 17:28:59 +03:00
for ( let i = 0 ; i < others . length ; i ++ ) {
2025-12-21 00:31:26 +02:00
const other = others [ i ] ;
2026-04-24 17:28:59 +03:00
showBusy ( ` Stitching vs ${ other . name } ... ( ${ i + 1 } / ${ others . length } ) ` ) ;
2025-12-21 00:31:26 +02:00
// Tiny pause to let UI render the text update
await new Promise ( r => setTimeout ( r , 10 ) ) ;
const fOther = await cvManager . feats ( other . blob ) ;
2026-04-24 17:28:59 +03:00
if ( fOther ) {
2025-12-21 00:31:26 +02:00
const res = cvManager . findH ( fNew , fOther ) ;
2026-04-24 17:28:59 +03:00
if ( res ) {
2025-12-21 00:31:26 +02:00
await db . addOverlap ( {
id : uuid ( ) ,
fromImageId : newId ,
toImageId : other . id ,
homography : res . hData ,
inverseHomography : res . invHData ,
matchCount : res . matches ,
isManual : false
} ) ;
matchesFound ++ ;
}
// Free memory for the 'other' image immediately
2026-04-24 17:28:59 +03:00
if ( fOther . kp ) fOther . kp . delete ( ) ;
if ( fOther . des ) fOther . des . delete ( ) ;
2025-12-21 00:31:26 +02:00
}
}
// Free memory for the 'new' image
2026-04-24 17:28:59 +03:00
if ( fNew . kp ) fNew . kp . delete ( ) ;
if ( fNew . des ) fNew . des . delete ( ) ;
2025-12-21 00:31:26 +02:00
2026-04-24 17:28:59 +03:00
} catch ( e ) {
2025-12-21 00:31:26 +02:00
console . error ( "Auto-stitch failed" , e ) ;
} finally {
hideBusy ( ) ;
// Refresh overlaps if we successfully matched anything
2026-06-26 14:53:45 +03:00
if ( ( await db . getOverlapsForImage ( newId ) ) . length > 0 ) {
2025-12-21 00:31:26 +02:00
await updateOverlapCache ( ) ;
renderPins ( ) ;
}
}
}
2026-04-27 18:02:04 +03:00
// --- NEW HELPER: Check Node Collision ---
async function checkNodeCollision ( nodeLabel , excludeNetId = null ) {
if ( ! nodeLabel || typeof currentBomId === 'undefined' ) return [ ] ;
const allNets = await db . getNets ( ) ;
const projectNets = allNets . filter ( n => n . projectId === currentBomId ) ;
const collisions = [ ] ;
for ( const net of projectNets ) {
if ( net . id === excludeNetId ) continue ;
if ( net . nodes && net . nodes . some ( n => n . label === nodeLabel ) ) {
collisions . push ( net . name ) ;
}
}
return collisions ;
}
2025-12-21 00:31:26 +02:00
const ImageGraph = {
// Helper: Invert 3x3 Matrix
invertH ( m ) {
if ( ! m || m . length !== 9 ) return null ;
const det = m [ 0 ] * ( m [ 4 ] * m [ 8 ] - m [ 7 ] * m [ 5 ] ) -
2026-04-24 17:28:59 +03:00
m [ 1 ] * ( m [ 3 ] * m [ 8 ] - m [ 5 ] * m [ 6 ] ) +
m [ 2 ] * ( m [ 3 ] * m [ 7 ] - m [ 4 ] * m [ 6 ] ) ;
2025-12-21 00:31:26 +02:00
if ( Math . abs ( det ) < 1e-10 ) return null ;
const invDet = 1 / det ;
return [
( m [ 4 ] * m [ 8 ] - m [ 5 ] * m [ 7 ] ) * invDet ,
( m [ 2 ] * m [ 7 ] - m [ 1 ] * m [ 8 ] ) * invDet ,
( m [ 1 ] * m [ 5 ] - m [ 2 ] * m [ 4 ] ) * invDet ,
( m [ 5 ] * m [ 6 ] - m [ 3 ] * m [ 8 ] ) * invDet ,
( m [ 0 ] * m [ 8 ] - m [ 2 ] * m [ 6 ] ) * invDet ,
( m [ 2 ] * m [ 3 ] - m [ 0 ] * m [ 5 ] ) * invDet ,
( m [ 3 ] * m [ 7 ] - m [ 4 ] * m [ 6 ] ) * invDet ,
( m [ 1 ] * m [ 6 ] - m [ 0 ] * m [ 7 ] ) * invDet ,
( m [ 0 ] * m [ 4 ] - m [ 1 ] * m [ 3 ] ) * invDet
] ;
} ,
// Returns array of { id, H, totalCost } for all reachable nodes
async solvePaths ( startImgId , cvMgr , db ) {
const allOverlaps = await db . _tx ( 'overlappedImages' , 'readonly' , s => s . getAll ( ) ) ;
// 1. Build Adjacency List
const graph = { } ;
allOverlaps . forEach ( ov => {
2026-04-24 17:28:59 +03:00
if ( ! graph [ ov . fromImageId ] ) graph [ ov . fromImageId ] = [ ] ;
2025-12-21 00:31:26 +02:00
// Forward: Source -> Target (Homography)
graph [ ov . fromImageId ] . push ( { id : ov . toImageId , H : ov . homography , cost : ov . isManual ? 1 : 1000 } ) ;
2026-04-24 17:28:59 +03:00
if ( ! graph [ ov . toImageId ] ) graph [ ov . toImageId ] = [ ] ;
2025-12-21 00:31:26 +02:00
// Backward: Target -> Source (Inverse)
graph [ ov . toImageId ] . push ( { id : ov . fromImageId , H : ov . inverseHomography , cost : ov . isManual ? 1 : 1000 } ) ;
} ) ;
// 2. Dijkstra Search
const results = [ ] ;
2026-04-24 17:28:59 +03:00
const queue = [ { id : startImgId , H : [ 1 , 0 , 0 , 0 , 1 , 0 , 0 , 0 , 1 ] , totalCost : 0 } ] ;
2025-12-21 00:31:26 +02:00
const visited = new Set ( ) ;
2026-04-24 17:28:59 +03:00
while ( queue . length > 0 ) {
2025-12-21 00:31:26 +02:00
queue . sort ( ( a , b ) => a . totalCost - b . totalCost ) ;
const curr = queue . shift ( ) ;
2026-04-24 17:28:59 +03:00
if ( visited . has ( curr . id ) ) continue ;
2025-12-21 00:31:26 +02:00
visited . add ( curr . id ) ;
// Add to results (excluding start node)
if ( curr . id !== startImgId ) {
results . push ( { id : curr . id , H : curr . H , totalCost : curr . totalCost } ) ;
}
2026-04-24 17:28:59 +03:00
if ( graph [ curr . id ] ) {
for ( const edge of graph [ curr . id ] ) {
if ( ! visited . has ( edge . id ) ) {
2025-12-21 00:31:26 +02:00
const H _next = cvMgr . multiplyH ( edge . H , curr . H ) ;
queue . push ( {
id : edge . id ,
H : H _next ,
totalCost : curr . totalCost + edge . cost
} ) ;
}
}
}
}
return results ;
}
} ;
/* --- NAVIGATION & HISTORY MANAGER --- */
const NavManager = {
init ( ) {
window . addEventListener ( 'popstate' , ( e ) => this . handlePopState ( e ) ) ;
document . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Escape' ) {
if ( history . state && history . state . context ) {
history . back ( ) ;
} else {
this . handleEscDirectly ( ) ;
}
}
} ) ;
window . addEventListener ( 'message' , ( e ) => {
if ( e . data . type === 'ESC_PRESSED' ) {
const visible = Array . from ( document . querySelectorAll ( '.modal-overlay' ) ) . some ( m => m . style . display === 'flex' ) ;
2026-04-24 17:28:59 +03:00
if ( visible ) history . back ( ) ;
2025-12-21 00:31:26 +02:00
}
} ) ;
document . querySelectorAll ( '.close-btn' ) . forEach ( btn => {
btn . onclick = ( e ) => {
e . preventDefault ( ) ; e . stopPropagation ( ) ;
history . back ( ) ;
} ;
} ) ;
const mm = document . getElementById ( 'mobile-menu-modal' ) ;
2026-04-24 17:28:59 +03:00
if ( mm ) {
mm . onclick = ( e ) => { if ( e . target === mm ) history . back ( ) ; } ;
2025-12-21 00:31:26 +02:00
}
this . applyPatches ( ) ;
} ,
pushState ( context , data = { } ) {
// Prevent duplicate pushes of the same context
if ( history . state && history . state . context === context ) return ;
history . pushState ( { context , ... data } , "" , "" ) ;
} ,
handlePopState ( e ) {
const state = e . state || { } ;
const ctx = state . context ;
// 1. Close Modals
document . querySelectorAll ( '.modal-overlay' ) . forEach ( el => el . style . display = 'none' ) ;
2026-04-24 17:28:59 +03:00
if ( typeof ImageImporter !== 'undefined' ) ImageImporter . close ( true ) ;
if ( typeof iframe !== 'undefined' ) { iframe . style . display = 'none' ; iframe . src = 'about:blank' ; }
2025-12-21 00:31:26 +02:00
// 2. Restore View
if ( ctx === 'map' ) {
switchView ( 'map' , true ) ; // Pass true to skip pushState
} else if ( ctx === 'nets' ) {
switchView ( 'nets' , true ) ;
} else if ( ctx === 'inspect' ) {
switchView ( 'inspect' , true ) ;
} else {
// Default to List
switchView ( 'list' , true ) ;
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
// Restore Tool if needed
if ( ctx === 'tool' ) this . restoreTool ( ) ;
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
// Restore Modals
if ( ctx && ctx . endsWith ( '-modal' ) ) {
const el = document . getElementById ( ctx ) ;
2026-04-24 17:28:59 +03:00
if ( el ) el . style . display = 'flex' ;
2025-12-21 00:31:26 +02:00
}
}
} ,
handleEscDirectly ( ) {
const visibleModal = Array . from ( document . querySelectorAll ( '.modal-overlay' ) ) . reverse ( ) . find ( m => m . style . display === 'flex' ) ;
if ( visibleModal ) {
visibleModal . style . display = 'none' ;
} else {
// If on any tab other than list, go back to list
const activeView = document . querySelector ( '.view-section.active' ) ;
if ( activeView && activeView . id !== 'view-list' ) {
switchView ( 'list' ) ;
}
}
} ,
restoreTool ( ) {
if ( iframe && iframe . src ) modal . style . display = 'flex' ;
} ,
applyPatches ( ) {
const originalSwitchView = switchView ;
// Wrap switchView to handle history pushing automatically
2026-04-24 17:28:59 +03:00
switchView = function ( v , fromHistory = false ) {
2025-12-21 00:31:26 +02:00
originalSwitchView ( v ) ;
if ( ! fromHistory ) {
NavManager . pushState ( v ) ;
}
} ;
const originalOpenToolD = openToolD ;
2026-04-24 17:28:59 +03:00
openToolD = async function ( idx , url , tit ) {
2025-12-21 00:31:26 +02:00
await originalOpenToolD ( idx , url , tit ) ;
NavManager . pushState ( 'tool' ) ;
} ;
const originalCloseTool = closeTool ;
2026-04-24 17:28:59 +03:00
closeTool = function ( ) {
if ( history . state && history . state . context === 'tool' ) history . back ( ) ;
2025-12-21 00:31:26 +02:00
else originalCloseTool ( ) ;
} ;
const wrapModalOpener = ( fnName , modalId ) => {
if ( typeof window [ fnName ] === 'function' ) {
const original = window [ fnName ] ;
2026-04-24 17:28:59 +03:00
window [ fnName ] = function ( ... args ) {
2025-12-21 00:31:26 +02:00
original . apply ( this , args ) ;
NavManager . pushState ( modalId ) ;
} ;
}
} ;
wrapModalOpener ( 'openBoardSettings' , 'board-settings-modal' ) ;
wrapModalOpener ( 'openDeviceSettings' , 'device-settings-modal' ) ;
wrapModalOpener ( 'openConnectionsModal' , 'connections-modal' ) ;
wrapModalOpener ( 'openImageSettings' , 'image-settings-modal' ) ;
wrapModalOpener ( 'openMobileMenu' , 'mobile-menu-modal' ) ;
2026-04-24 17:28:59 +03:00
if ( typeof stitchEditor !== 'undefined' && stitchEditor ) {
2025-12-21 00:31:26 +02:00
const origStitchOpen = stitchEditor . open . bind ( stitchEditor ) ;
2026-04-24 17:28:59 +03:00
stitchEditor . open = async function ( src , dst ) {
2025-12-21 00:31:26 +02:00
await origStitchOpen ( src , dst ) ;
NavManager . pushState ( 'stitch-modal' ) ;
} ;
}
2026-04-24 17:28:59 +03:00
if ( typeof ImageImporter !== 'undefined' ) {
2025-12-21 00:31:26 +02:00
const origImgOpen = ImageImporter . openModal . bind ( ImageImporter ) ;
2026-04-24 17:28:59 +03:00
ImageImporter . openModal = function ( mode ) {
2025-12-21 00:31:26 +02:00
origImgOpen ( mode ) ;
2026-04-24 17:28:59 +03:00
if ( ! history . state || history . state . context !== 'import-modal' ) {
NavManager . pushState ( 'import-modal' ) ;
2025-12-21 00:31:26 +02:00
}
} ;
2026-04-24 17:28:59 +03:00
ImageImporter . close = function ( silent = false ) {
2025-12-21 00:31:26 +02:00
this . modal . style . display = 'none' ;
this . stopStream ( ) ;
this . sourceImage = null ;
this . cropRect = null ;
2026-04-24 17:28:59 +03:00
if ( ! silent && history . state && history . state . context === 'import-modal' ) {
2025-12-21 00:31:26 +02:00
history . back ( ) ;
}
} ;
}
}
} ;
2026-05-23 11:18:19 +03:00
function passphraseStrength ( p ) {
if ( ! p ) return { label : '' , color : '' } ;
const score = ( p . length >= 20 ? 3 : p . length >= 12 ? 2 : p . length >= 8 ? 1 : 0 )
+ ( /[A-Z]/ . test ( p ) ? 1 : 0 )
+ ( /[0-9]/ . test ( p ) ? 1 : 0 )
+ ( /[^A-Za-z0-9]/ . test ( p ) || / / . test ( p ) ? 1 : 0 ) ;
if ( score >= 5 ) return { label : '🟢 Strong' , color : '#059669' } ;
if ( score >= 3 ) return { label : '🟡 Moderate' , color : '#d97706' } ;
return { label : '🔴 Weak' , color : '#ef4444' } ;
}
/ * *
* @ param { string } title
* @ param { object } opts
* @ param { boolean } opts . allowEmpty — show "No Passphrase" button ( export )
* @ param { boolean } opts . confirmNew — show confirm field ( export — new passphrase )
* @ returns Promise < string | null >
* non - empty string → passphrase
* '' → user chose no encryption
* null → cancelled
* /
function requestPassphrase ( title , opts = { } ) {
return new Promise ( ( resolve ) => {
const modal = document . getElementById ( 'generic-input-modal' ) ;
const helpToggle = document . getElementById ( 'gim-help-toggle' ) ;
const helpContent = document . getElementById ( 'gim-help-content' ) ;
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 = 'Passphrase' ;
inp . type = 'password' ;
inp . value = '' ;
inp . placeholder = 'Enter passphrase…' ;
inp . autocomplete = opts . confirmNew ? 'new-password' : 'current-password' ;
helpToggle . style . display = 'none' ;
helpContent . style . display = 'block' ;
// ── Show/hide toggle ──────────────────────────────────────
const inputWrap = document . createElement ( 'div' ) ;
inputWrap . style . cssText = 'position:relative; display:flex; align-items:center;' ;
inp . parentNode . insertBefore ( inputWrap , inp ) ;
inputWrap . appendChild ( inp ) ;
const eyeBtn = document . createElement ( 'button' ) ;
eyeBtn . type = 'button' ;
eyeBtn . textContent = '👁' ;
eyeBtn . title = 'Show/hide passphrase' ;
eyeBtn . style . cssText = 'position:absolute; right:8px; background:none; border:none; cursor:pointer; color:var(--text2); font-size:14px; padding:0; line-height:1;' ;
eyeBtn . onclick = ( ) => {
const show = inp . type === 'password' ;
inp . type = show ? 'text' : 'password' ;
if ( confirmInp ) confirmInp . type = inp . type ;
eyeBtn . style . opacity = show ? '1' : '0.4' ;
inp . focus ( ) ;
} ;
inputWrap . appendChild ( eyeBtn ) ;
// ── Confirm field (export only) ───────────────────────────
let confirmInp = null ;
let confirmWrap = null ;
if ( opts . confirmNew ) {
confirmWrap = document . createElement ( 'div' ) ;
confirmWrap . style . cssText = 'display:flex; flex-direction:column; gap:4px; margin-top:8px;' ;
const confirmLabel = document . createElement ( 'label' ) ;
confirmLabel . textContent = 'Confirm Passphrase' ;
confirmLabel . style . cssText = 'font-size:0.8rem; color:var(--text2);' ;
confirmInp = document . createElement ( 'input' ) ;
confirmInp . type = 'password' ;
confirmInp . autocomplete = 'new-password' ;
confirmInp . placeholder = 'Repeat passphrase…' ;
confirmInp . style . cssText = inp . style . cssText || 'width:100%; padding:0.5rem; background:var(--input-bg, #1e1e2e); border:1px solid var(--border, #444); border-radius:4px; color:inherit; font-size:inherit;' ;
confirmWrap . appendChild ( confirmLabel ) ;
confirmWrap . appendChild ( confirmInp ) ;
inputWrap . parentNode . insertBefore ( confirmWrap , helpContent ) ;
}
// ── Strength + mismatch feedback ──────────────────────────
const updateFeedback = ( ) => {
const v = inp . value ;
if ( confirmInp ) {
// Confirm mode: show mismatch or strength
if ( confirmInp . value && confirmInp . value !== v ) {
helpContent . innerHTML = '<span style="color:#ef4444; font-size:0.8rem; font-weight:600;">⚠ Passphrases do not match</span>' ;
return ;
}
}
if ( ! v ) {
helpContent . innerHTML = opts . allowEmpty
? '<span style="color:#94a3b8; font-size:0.8rem;">Leave empty or click "No Passphrase" to save without encryption.</span>'
: '<span style="color:#94a3b8; font-size:0.8rem;">Enter the passphrase used when this file was saved.</span>' ;
return ;
}
if ( opts . confirmNew ) {
const s = passphraseStrength ( v ) ;
helpContent . innerHTML = ` <span style="color: ${ s . color } ; font-size:0.8rem; font-weight:600;"> ${ s . label } </span> ` ;
} else {
helpContent . innerHTML = '' ; // no strength hint on import
}
} ;
let resultToResolve = null ;
const cleanup = ( ) => {
inp . type = 'text' ;
inp . placeholder = '' ;
inp . autocomplete = 'off' ;
inp . oninput = null ;
inp . onkeydown = null ;
// Restore DOM
inputWrap . parentNode . insertBefore ( inp , inputWrap ) ;
inputWrap . remove ( ) ;
if ( confirmWrap ) confirmWrap . remove ( ) ;
helpContent . style . display = 'none' ;
helpContent . innerHTML = '' ;
window . removeEventListener ( 'popstate' , onPopState ) ;
modal . style . display = 'none' ;
resolve ( resultToResolve ) ;
} ;
const onPopState = ( ) => cleanup ( ) ;
const commit = ( v ) => {
resultToResolve = v ;
if ( history . state && history . state . context === modalContext ) history . back ( ) ;
else cleanup ( ) ;
} ;
const tryCommit = ( ) => {
const v = inp . value . trim ( ) ;
if ( confirmInp ) {
if ( ! v ) { commit ( '' ) ; return ; } // empty = no encryption
if ( confirmInp . value . trim ( ) !== v ) {
// Flash mismatch — don't close
helpContent . innerHTML = '<span style="color:#ef4444; font-size:0.8rem; font-weight:600;">⚠ Passphrases do not match — please re-enter</span>' ;
confirmInp . focus ( ) ;
return ;
}
}
commit ( v || '' ) ;
} ;
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 ) ;
newOk . onclick = ( e ) => { e . stopPropagation ( ) ; tryCommit ( ) ; } ;
newCancel . onclick = ( e ) => { e . stopPropagation ( ) ; commit ( null ) ; } ;
modal . querySelector ( '.close-btn' ) . onclick = ( e ) => { e . stopPropagation ( ) ; commit ( null ) ; } ;
if ( opts . allowEmpty ) {
newExtra . style . display = 'block' ;
newExtra . innerText = 'No Passphrase' ;
newExtra . className = 'secondary' ;
newExtra . style . padding = '0.6rem 1rem' ;
newExtra . style . marginRight = 'auto' ;
newExtra . onclick = ( e ) => { e . stopPropagation ( ) ; commit ( '' ) ; } ;
} else {
newExtra . style . display = 'none' ;
}
inp . oninput = updateFeedback ;
if ( confirmInp ) confirmInp . oninput = updateFeedback ;
inp . onkeydown = ( e ) => { if ( e . key === 'Enter' ) { e . preventDefault ( ) ; tryCommit ( ) ; } } ;
if ( confirmInp ) confirmInp . onkeydown = ( e ) => { if ( e . key === 'Enter' ) { e . preventDefault ( ) ; tryCommit ( ) ; } } ;
updateFeedback ( ) ;
if ( ! history . state || history . state . context !== modalContext )
history . pushState ( { context : modalContext } , '' , '' ) ;
window . addEventListener ( 'popstate' , onPopState ) ;
modal . style . display = 'flex' ;
inp . focus ( ) ;
} ) ;
}
2025-12-21 18:45:40 +02:00
/ * *
* 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' ) ;
2025-12-26 14:55:08 +02:00
const helpToggle = document . getElementById ( 'gim-help-toggle' ) ;
const helpContent = document . getElementById ( 'gim-help-content' ) ;
2025-12-21 18:45:40 +02:00
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 || '' ;
2025-12-26 14:55:08 +02:00
// --- Handle Help Text ---
helpContent . style . display = 'none' ; // Reset to hidden
if ( opts . helpHtml ) {
helpToggle . style . display = 'block' ;
helpContent . innerHTML = opts . helpHtml ;
helpToggle . onclick = ( e ) => {
e . stopPropagation ( ) ;
const isHidden = helpContent . style . display === 'none' ;
helpContent . style . display = isHidden ? 'block' : 'none' ;
2026-04-24 17:28:59 +03:00
if ( isHidden ) inp . focus ( ) ; // Keep focus
2025-12-26 14:55:08 +02:00
} ;
} else {
helpToggle . style . display = 'none' ;
}
2025-12-21 18:45:40 +02:00
let resultToResolve = null ;
2026-04-27 18:02:04 +03:00
let isInputValid = true ; // Updated by opts.validate; always true when no validator is set
2025-12-21 18:45:40 +02:00
// 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 ) => {
2026-04-27 18:02:04 +03:00
// Block submission when a validator is attached and the current value is invalid.
// Cancel (null) and extra-button sentinel values (e.g. '__DELETE__') always pass through.
if ( v !== null && opts . validate && ! isInputValid && v !== '__DELETE__' ) {
inp . style . borderColor = '#ef4444' ;
inp . style . backgroundColor = '#fef2f2' ;
setTimeout ( ( ) => {
inp . style . borderColor = '#cbd5e1' ;
inp . style . backgroundColor = '' ;
} , 400 ) ;
return ; // Block submission
}
2025-12-21 18:45:40 +02:00
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 ) ; } ;
2026-04-24 17:28:59 +03:00
if ( opts . extraBtn ) {
2025-12-21 18:45:40 +02:00
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 ) => {
2026-04-24 17:28:59 +03:00
if ( e . key === 'Enter' ) {
2025-12-21 18:45:40 +02:00
e . preventDefault ( ) ;
newOk . click ( ) ;
}
// Let NavManager handle Escape -> history.back()
} ;
2026-04-27 18:02:04 +03:00
// Generic live-validation hook.
// opts.validate: async (rawValue: string, ...args) => { sanitized?: string, isValid: boolean, feedbackHtml: string }
// The function owns all domain-specific logic (sanitization, format checks, collision lookups,
// etc.). requestInput only wires up the plumbing and gates OK on isValid.
if ( opts . validate ) {
const validateArgs = opts . validateArgs ? opts . validateArgs : [ ] ;
inp . addEventListener ( 'input' , async ( ) => {
const result = await opts . validate ( inp . value , ... validateArgs ) ;
// Apply sanitized value if the validator changed it
if ( result . sanitized !== undefined && inp . value !== result . sanitized ) {
const pos = inp . selectionStart ;
inp . value = result . sanitized ;
inp . setSelectionRange ( pos , pos ) ;
}
isInputValid = result . isValid ;
// Always show the help area while the user is typing so feedback is visible
helpToggle . style . display = 'block' ;
helpContent . style . display = 'block' ;
helpContent . innerHTML = result . feedbackHtml + ( opts . helpHtml || '' ) ;
} ) ;
// Trigger once on open so the field is validated immediately
setTimeout ( ( ) => inp . dispatchEvent ( new Event ( 'input' ) ) , 50 ) ;
}
2025-12-21 18:45:40 +02:00
// 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 ( ) ;
} ) ;
}
2026-04-27 18:02:04 +03:00
/ * *
* Validator for net node names ( for requestInput ) .
* /
async function validateNetName ( raw , netId ) {
// 1. Sanitize: strip spaces, enforce single dot with digits-only suffix
let v = raw . replace ( /\s+/g , '' ) ;
const parts = v . split ( '.' ) ;
if ( parts . length > 2 ) {
v = parts [ 0 ] + '.' + parts . slice ( 1 ) . join ( '' ) . replace ( /[^0-9]/g , '' ) ;
} else if ( parts . length === 2 ) {
v = parts [ 0 ] + '.' + parts [ 1 ] . replace ( /[^0-9]/g , '' ) ;
}
if ( ! v ) {
return { sanitized : v , isValid : false , feedbackHtml : '' } ;
}
// 2. Format check
const validFormat = /^[A-Za-z0-9_-]+\.[1-9][0-9]*$/ . test ( v ) ;
if ( ! validFormat ) {
return {
sanitized : v ,
isValid : false ,
feedbackHtml : '<div style="color:#ef4444; font-weight:bold; margin-bottom:4px;">❌ Format must be Ref.Pin (e.g. R1.1)</div>' ,
} ;
}
// 3. Collision check
const collisions = await checkNodeCollision ( v , netId ) ;
if ( collisions . length > 0 ) {
return {
sanitized : v ,
isValid : true , // Warn but still allow — user may be intentionally reassigning
feedbackHtml : ` <div style="color:#d97706; font-weight:bold; margin-bottom:4px;">⚠️ Node already used in net(s): ${ collisions . join ( ', ' ) } </div> ` ,
} ;
}
return {
sanitized : v ,
isValid : true ,
feedbackHtml : '<div style="color:#10b981; font-weight:bold; margin-bottom:4px;">✅ Valid format</div>' ,
} ;
}
2025-12-22 16:56:46 +02:00
/* --- URL IMPORT LOGIC --- */
async function importDeviceFromURL ( ) {
// Use existing generic input modal
const url = await requestInput ( "Import Device" , "ZIP URL" , "https://" ) ;
if ( url ) {
await processUrlImport ( url . trim ( ) ) ;
}
}
async function processUrlImport ( url ) {
if ( ! url ) return ;
showBusy ( "Downloading Device..." ) ;
try {
const response = await fetch ( url ) ;
if ( ! response . ok ) {
throw new Error ( ` HTTP Error ${ response . status } : ${ response . statusText } ` ) ;
}
const blob = await response . blob ( ) ;
// 1. Determine Filename
// Clean query params (e.g. ?token=...) to check extension
const cleanUrl = url . split ( '?' ) [ 0 ] . split ( '#' ) [ 0 ] ;
let filename = cleanUrl . substring ( cleanUrl . lastIndexOf ( '/' ) + 1 ) ;
// 2. Fallback if filename is empty or doesn't look like a zip
// (importFile logic relies on .zip extension to detect mode)
if ( ! filename || ! filename . toLowerCase ( ) . endsWith ( '.zip' ) ) {
filename = "downloaded_device.zip" ;
}
// 3. Create Virtual File
// Note: 'new File' is supported in all modern browsers (Safari 10+, Chrome, FF)
const file = new File ( [ blob ] , filename , { type : blob . type || 'application/zip' } ) ;
hideBusy ( ) ;
// 4. Handover to existing Import logic
await importFile ( file ) ;
} catch ( e ) {
hideBusy ( ) ;
console . error ( "URL Import Error:" , e ) ;
// User-friendly error regarding CORS
let msg = ` Import Failed. \n \n Error: ${ e . message } ` ;
if ( e . name === 'TypeError' && e . message === 'Failed to fetch' ) {
msg += ` \n \n Possible Cause: CORS Restriction. \n The server hosting this ZIP must send the header: \n "Access-Control-Allow-Origin: *" ` ;
}
alert ( msg ) ;
}
}
2026-04-24 17:28:59 +03:00
new ResizeObserver ( ( ) => {
const frame = document . getElementById ( 'schema-frame' ) ;
const mc = document . querySelector ( '.main-content' ) ;
if ( frame ? . _schemaReady && frame . contentWindow && mc ) {
frame . contentWindow . postMessage ( { type : 'SCHEMA_RESIZE' } , '*' ) ;
}
} ) . observe ( document . querySelector ( '.main-content' ) ) ;
2025-12-21 00:31:26 +02:00
// Start
window . onload = init ;
window . exportKiCad = ( ) => {
2025-12-21 18:45:40 +02:00
if ( window . netManager ) window . netManager . exportKiCad ( ) ;
2025-12-21 00:31:26 +02:00
} ;
2026-04-24 17:28:59 +03:00
window . exportSpice = ( ) => {
if ( window . netManager ) window . netManager . exportSpice ( ) ;
} ;
2025-12-21 00:31:26 +02:00
window . startNewNet = ( ) => {
2025-12-21 18:45:40 +02:00
if ( window . inspector ) window . inspector . startNewNet ( ) ;
2025-12-21 00:31:26 +02:00
} ;