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
<!DOCTYPE html>
< html lang = "en" >
< head >
< script > window . dataLayer = window . dataLayer || [ ] ; function gtag ( ) { dataLayer . push ( arguments ) } gtag ( "consent" , "default" , { ad _storage : "granted" , ad _user _data : "granted" , ad _personalization : "granted" , analytics _storage : "granted" } ) ; gtag ( "consent" , "default" , { ad _storage : "denied" , ad _user _data : "denied" , ad _personalization : "denied" , analytics _storage : "denied" , region : "AT,BE,BG,HR,CY,CZ,DK,EE,FI,FR,DE,GR,HU,IE,IT,LV,LT,LU,MT,NL,PL,PT,RO,SK,SI,ES,SE,IS,LI,NO,GB,CH,UA" . split ( "," ) } ) ; < / script >
<!-- Google tag (gtag.js) -->
< script async src = "https://www.googletagmanager.com/gtag/js?id=G-X1MKL6C4B0" > < / script >
< script > window . dataLayer = window . dataLayer || [ ] ; function gtag ( ) { dataLayer . push ( arguments ) } gtag ( "js" , new Date ( ) ) ; gtag ( "config" , "G-X1MKL6C4B0" ) ; < / script >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" >
2026-01-30 23:48:18 +02:00
< link rel = "icon" href = "favicon.ico" >
2025-12-21 00:31:26 +02:00
< title > PCB ReTrace Studio< / title >
2026-05-23 11:18:19 +03:00
< script src = "https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.8.26/dist/zip.min.js" > < / script >
2025-12-21 00:31:26 +02:00
<!-- LIBRARIES -->
< script async src = "https://docs.opencv.org/4.x/opencv.js" type = "text/javascript" > < / script >
< script src = "spyglass.js" > < / script >
<!-- CORE LOGIC -->
2026-04-24 17:28:59 +03:00
< script src = "db-core.js" > < / script >
2025-12-21 00:31:26 +02:00
< script src = "cv-core.js" > < / script >
< script src = "stitch-editor.js" > < / script >
< script src = "studio.js" defer > < / script >
2025-12-21 18:45:40 +02:00
< script src = "canvas-ui.js" > < / script >
< script src = "nets.js" > < / script >
< script src = "inspector.js" > < / script >
2025-12-21 00:31:26 +02:00
< link rel = "stylesheet" href = "common.css" >
< style >
/* VARIABLES */
:root { --primary: #2563eb; --surface: #ffffff; --danger: #ef4444; --success: #059669; }
/* HEADER & NAV */
header { background: #fff; padding: 0.5rem 1rem; border-bottom: 1px solid var(--border); box-shadow: 0 1px 2px rgba(0,0,0,0.05); z-index: 50; flex-shrink: 0; }
.top-bar { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; max-width: 1400px; margin: 0 auto; width: 100%; }
h1 { margin: 0; font-size: 1.1rem; color: #0f172a; white-space: nowrap; margin-right: 1rem;}
.project-controls { display: flex; align-items: center; gap: 0.5rem; flex: 1; min-width: 0; flex-wrap: wrap; }
.nav-group { display: flex; align-items: center; gap: 0.3rem; flex: 1; min-width: 0; } /* min-width:0 allows shrinking */
.nav-label { font-size: 0.65rem; color: #64748b; text-transform: uppercase; font-weight: 700; white-space: nowrap; }
.separator { color: #cbd5e1; font-size: 1.2rem; font-weight: 300; display: block; margin: 0 0.2rem; }
@media(min-width: 600px) { .separator { display: block; } }
.desktop-actions { display: flex; gap: 0.3rem; align-items: center; }
.mobile-menu-btn { display: none; background: transparent; border: none; font-size: 1.2rem; color: #64748b; padding: 0 0.2rem; cursor: pointer; line-height: 1; }
/* CONTROLS */
.custom-select-wrapper { position: relative; flex: 1; }
.custom-select { appearance: none; padding: 0 1.2rem 0 0.5rem; font-size: 0.85rem; border: 1px solid var(--border); border-radius: 0.3rem; background: #fff; color: var(--primary); font-weight: 600; cursor: pointer; height: 1.6rem; width: 100%; }
.select-arrow { position: absolute; right: 0.4rem; top: 50%; transform: translateY(-50%); pointer-events: none; font-size: 0.6rem; color: #64748b; }
button { border: none; border-radius: 0.3rem; font-weight: 600; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
button.secondary { background: #fff; color: #475569; border: 1px solid var(--border); }
button.primary { background: var(--primary); color: white; border: 1px solid var(--primary); }
button.danger { background: #fee2e2; color: var(--danger); border: 1px solid #fecaca; }
button.icon-btn { padding: 0; width: 1.8rem; height: 1.8rem; font-size: 1rem; }
/* TABS */
.tabs { display: flex; gap: 1rem; margin-top: 0.2rem; max-width: 1400px; margin-left: auto; margin-right: auto; padding: 0 0.5rem; }
.tab-btn { padding: 0.2rem 0; cursor: pointer; color: #64748b; font-weight: 600; border-bottom: 2px solid transparent; font-size: 0.9rem; }
.tab-btn:hover { color: var(--primary); }
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
/* LAYOUT CONTAINERS */
.main-content { flex: 1; position: relative; width: 100%; background: var(--bg); overflow: hidden; }
.view-section { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: none; flex-direction: column; }
.view-section.active { display: flex; z-index: 10; }
/* LIST VIEW */
.list-fixed-top {
flex-shrink: 0;
background: var(--bg);
border-bottom: 1px solid var(--border);
padding: 0.25rem;
z-index: 20;
width: 100%;
max-width: 1200px;
margin: 0 auto;
box-sizing: border-box; /* Fixes width calculation */
}
.actions-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 0.25rem;
flex-wrap: nowrap; /* Force single row */
align-items: center;
width: 100%;
}
/* Sort Dropdown: Don't let it grow infinitely */
.actions-bar .custom-select-wrapper {
flex: 0 0 auto;
min-width: 6rem;
}
/* Buttons: Don't shrink or wrap */
.actions-bar button, .actions-bar .dropdown button {
font-size: 0.75rem;
padding: 0 0.8rem;
height: 1.6rem;
white-space: nowrap;
flex-shrink: 0;
}
/* Select inside toolbar */
.actions-bar .custom-select {
padding: 0.3rem 1.4rem 0.3rem 0.5rem;
}
/* SCROLL AREA */
.list-scroll-area {
flex: 1;
overflow-y: auto;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 0.5rem 0.5rem 0.5rem;
box-sizing: border-box; /* Fixes width calculation */
-webkit-overflow-scrolling: touch;
}
/* FORM */
2026-04-24 17:28:59 +03:00
.add-form-container {
2025-12-21 00:31:26 +02:00
display: flex;
gap: 0.4rem;
background: #fff;
padding: 0.25rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
align-items: flex-start;
}
.editor-visuals {
display: flex;
flex-direction: row; /* Side-by-Side: Spyglass + Thumbs */
gap: 0.5rem;
flex-shrink: 0;
}
#inline-thumbs {
display: flex;
flex-direction: column; /* Vertical Stack */
gap: 4px;
overflow-y: auto; /* Scroll Vertically */
overflow-x: hidden;
height: 200px; /* Match Spyglass Height */
width: 72px; /* 60px thumb + scrollbar space */
padding-right: 2px;
border-left: 1px solid #e2e8f0;
padding-left: 4px;
/* Reset old styles */
min-height: 0;
border-top: none;
padding-top: 0;
}
.form-preview {
width: 200px;
height: 200px;
flex-shrink: 0;
background: #e2e8f0;
border-radius: 0.3rem;
border: 1px solid var(--border);
position: relative;
overflow: hidden;
display: flex; justify-content: center; align-items: center;
}
.thumb-promote-btn {
position: absolute;
top: 0; right: 0;
background: #f59e0b; /* Amber/Gold */
color: white;
border: none;
width: 18px; height: 18px;
font-size: 12px; line-height: 1;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
z-index: 10;
box-shadow: -1px 1px 2px rgba(0,0,0,0.2);
}
.thumb-promote-btn:hover { background: #d97706; }
.thumb-locate-btn {
position: absolute; top: 0; left: 0;
background: #3b82f6; /* Blue */
color: white; border: none;
width: 18px; height: 18px;
font-size: 10px; line-height: 1;
display: flex; align-items: center; justify-content: center;
cursor: pointer; z-index: 10;
box-shadow: 1px 1px 2px rgba(0,0,0,0.2);
}
.thumb-locate-btn:hover { background: #2563eb; }
canvas#preview-canvas { display: block; width: 100%; height: 100%; object-fit: contain; cursor: crosshair; }
.form-preview.empty::after { content: "Click Map"; color:#94a3b8; font-size:0.75rem; font-weight: 600; position: absolute; }
.preview-overlay { position: absolute; bottom: 0; right: 0; background: rgba(0,0,0,0.6); color: white; font-size: 0.6rem; padding: 1px 4px; pointer-events: none; border-top-left-radius: 3px; display: none; }
.form-preview.has-image .preview-overlay { display: block; }
.form-preview.empty canvas { display: none; }
/* GLOBAL INPUT STYLE */
input {
padding: 0.3rem 0.5rem;
border: 1px solid var(--border);
border-radius: 0.3rem;
width: 100%;
box-sizing: border-box;
font-size: 0.85rem;
height: 100%;
}
/* Tighter Table Rows */
td, th { padding: 0.25rem 0.4rem; }
/* COMPACT EDITOR LAYOUT (2-Line) */
.form-inputs {
display: flex;
flex-direction: column;
gap: 0.4rem;
flex: 1;
min-width: 0;
justify-content: center;
}
.form-row {
display: flex;
gap: 0.4rem;
width: 100%;
}
.input-group {
display: flex;
align-items: stretch;
border: 1px solid var(--border);
border-radius: 0.3rem;
background: #fff;
overflow: hidden;
min-width: 0;
height: 1.7rem; /* Fixed comfortable touch height */
}
.input-group:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary);
}
.input-group-label {
background: #f8fafc;
color: #64748b;
font-size: 0.6rem;
font-weight: 700;
padding: 0 0.4rem;
display: flex;
align-items: center;
border-right: 1px solid var(--border);
text-transform: uppercase;
flex-shrink: 0;
user-select: none;
}
/* Override global input style when inside a group */
.input-group input {
border: none;
border-radius: 0;
height: 100%;
}
.input-group input:focus { outline: none; }
/* Tools inside input group */
#new-part-tool-container {
display: flex;
align-items: center;
padding-right: 4px;
background: #fff;
}
#new-part-tool-container .tool-btn {
height: 1.4rem; width: 1.4rem;
margin-left: 2px;
}
.btn-save-icon {
flex-shrink: 0;
width: 2.2rem;
font-size: 1.2rem;
padding: 0;
height: 1.7rem;
border-radius: 0.3rem;
}
/* TABLE */
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 0.5rem; box-shadow: 0 1px 2px rgba(0,0,0,0.05); table-layout: fixed; }
th { background: #f8fafc; font-size: 0.7rem; text-transform: uppercase; color: #475569; padding: 0.4rem 0.5rem; position: sticky; top: 0; z-index: 5; border-bottom: 1px solid var(--border); }
td { padding: 0.4rem 0.5rem; text-align: left; border-bottom: 1px solid var(--border); font-size: 0.9rem; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
tbody tr:hover { background-color: #f8fafc; cursor: pointer; }
tbody tr.editing { background-color: #e0f2fe !important; border-left: 3px solid var(--primary); }
.val-cell { display: flex; align-items: center; justify-content: space-between; gap: 0.2rem; width: 100%; overflow: hidden;}
.val-text { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.tool-group { display: flex; gap: 0.2rem; align-items: center; flex-wrap: nowrap; flex-shrink: 0; }
.tool-btn { background: #e0f2fe; color: var(--primary); width: 1.5rem; height: 1.5rem; padding: 0; display: flex; align-items: center; justify-content: center; border-radius: 0.3rem; cursor: pointer; border: 1px solid transparent; flex-shrink: 0; }
.tool-btn svg { width: 0.9rem; height: 0.9rem; }
.copy-btn { background: #fff; border: 1px solid #cbd5e1; color: #64748b; flex-shrink: 0; width: 1.5rem; height: 1.5rem;}
.dropdown { position: relative; display: inline-block; }
.dropdown button { width: 100%; }
.dropdown-content { display: none; position: absolute; left: 0; top: 100%; margin-top: 0.2rem; background-color: #fff; min-width: 140px; box-shadow: 0 4px 10px rgba(0,0,0,0.1); border: 1px solid var(--border); border-radius: 0.4rem; z-index: 999; }
.dropdown-content a { color: var(--text); padding: 0.5rem 0.8rem; text-decoration: none; display: block; font-size: 0.8rem; cursor: pointer; }
.dropdown-content a:hover { background-color: #f1f5f9; color: var(--primary); }
/* MAP VIEW */
#view-map { padding: 0; overflow: hidden; background-color: #334155 !important; }
.map-toolbar { position: absolute; top: 0.5rem; left: 0.5rem; z-index: 100; display: flex; gap: 0.3rem; background: #ffffff; padding: 0.3rem; border-radius: 0.3rem; box-shadow: 0 4px 10px rgba(0,0,0,0.3); }
/* MAP VIEWPORT - ONLY PLACE WITH TOUCH RESTRICTIONS */
.map-viewport {
width: 100%; height: 100%;
cursor: grab;
touch-action: none; /* Disable browser handling here only */
position: relative; overflow: hidden;
display: flex; align-items: center; justify-content: center;
}
.map-content { position: absolute; top: 0; left: 0; transform-origin: 0 0; z-index: 1; }
.pcb-image { display: block; pointer-events: none; max-width: none; user-select: none; }
.map-pin { position: absolute; width: 0; height: 0; pointer-events: auto; z-index: 50; }
.pin-marker { position: absolute; bottom: 0; left: -10px; width: 20px; height: 20px; background: var(--primary); border: 2px solid #fff; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); box-shadow: 0 2px 4px rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; color: white; font-size: 9px; font-weight: bold; transform-origin: 50% 50%; cursor: pointer; }
.pin-marker span { transform: rotate(45deg); display:block; }
.map-pin:hover .pin-marker { transform: rotate(-45deg) scale(1.3); background: #ef4444; z-index: 60; }
.empty-state { text-align: center; color: #94a3b8; margin-top: 3rem; pointer-events: none; width:100%; }
/* MODALS */
/* MODAL STACKING ORDER */
#image-settings-modal { z-index: 1000; } /* Base Level */
2026-04-24 17:28:59 +03:00
#connections-modal { z-index: 1050; } /* Level 2 */
#stitch-modal { z-index: 1060; } /* Level 3 */
#confirmation-modal { z-index: 1100; } /* Top Level (Alerts) */
2025-12-21 00:31:26 +02:00
.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 1000; }
.modal-content { width: 95%; max-width: 900px; height: 90vh; background: #f4f4f9; border-radius: 0.5rem; position: relative; overflow: hidden; display: flex; flex-direction: column; }
.modal-header { padding: 0.5rem 1rem; background: #fff; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); }
.close-btn { background: transparent; color: #64748b; font-size: 1.5rem; padding: 0; }
iframe { width: 100%; flex: 1; border: none; display: none; }
#tool-selector { flex: 1; display: none; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; }
.selector-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; width: 100%; max-width: 600px; }
.tool-card { background: white; border: 1px solid var(--border); padding: 1.5rem; border-radius: 0.5rem; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; cursor: pointer; text-align: center; }
.tool-card:hover { border-color: var(--primary); box-shadow: 0 4px 6px rgba(0,0,0,0.05); }
.tool-card-icon { width: 4rem; height: 4rem; } .tool-card-icon svg { width: 100%; height: 100%; } .tool-card-title { font-weight: 700; color: #334155; }
#board-settings-form, #device-settings-form { padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; }
#board-settings-form label, #device-settings-form label { font-size: 0.8rem; font-weight: 700; color: #64748b; text-transform: uppercase; margin-bottom: 0.2rem; display: block; }
#board-settings-form input, #board-settings-form select,
#device-settings-form input, #device-settings-form select { width: 100%; padding: 0.6rem; border: 1px solid var(--border); border-radius: 0.3rem; box-sizing: border-box; font-size: 1rem; }
/* STITCH MODAL */
#stitch-modal .modal-content { max-width: 95vw; height: 90vh; }
.stitch-split { display: flex; flex: 1; gap: 5px; overflow: hidden; background: #000; }
2025-12-21 18:45:40 +02:00
@media (orientation: portrait) {
.stitch-split { flex-direction: column; }
}
2025-12-21 00:31:26 +02:00
.stitch-pane { flex: 1; position: relative; overflow: hidden; border: 1px solid #444; }
.stitch-pane canvas { display: block; width: 100%; height: 100%; cursor: grab; }
.stitch-pane canvas:active { cursor: grabbing; }
.stitch-toolbar { padding: 10px; background: #fff; border-top: 1px solid #ccc; display: flex; justify-content: flex-end; gap: 10px; }
/* IMPORT MODAL */
#import-modal .modal-content { max-width: 95vw; height: 90vh; background: #0f172a; color: white; border: 1px solid #334155; }
#import-modal .modal-header { background: #1e293b; border-bottom: 1px solid #334155; color: white; }
#import-modal .modal-header h3 { color: white; }
#import-modal .close-btn { color: #94a3b8; }
#import-modal .actions-bar { background: #1e293b; border-top: 1px solid #334155; padding: 10px; color: white; }
.import-viewport { flex: 1; position: relative; display: flex; align-items: center; justify-content: center; overflow: hidden; background: radial-gradient(#334155 1px, transparent 1px) 0 0 / 20px 20px; }
#import-canvas { max-width: 100%; max-height: 100%; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5); }
#import-video { width: 100%; height: 100%; object-fit: contain; }
/* Fix for Inspect Tab Layout */
#view-inspect {
2025-12-21 18:45:40 +02:00
display: none;
flex-direction: column;
height: 100%;
width: 100%;
background: #0f172a;
color: white;
position: relative;
overflow: hidden; /* Prevent body scroll bars */
2025-12-21 00:31:26 +02:00
}
/* When active, force flex display to enable the column layout */
#view-inspect.active {
2025-12-21 18:45:40 +02:00
display: flex !important;
2025-12-21 00:31:26 +02:00
}
/* Ensure the grid fills remaining space */
#inspect-grid {
2025-12-21 18:45:40 +02:00
flex: 1;
min-height: 0; /* Critical for nested flex scrolling/sizing */
2025-12-21 00:31:26 +02:00
}
/* Add to common.css or styles */
.net-chip {
2025-12-21 18:45:40 +02:00
background: #e2e8f0;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.85rem;
border: 1px solid #cbd5e1;
cursor: pointer;
2025-12-21 00:31:26 +02:00
}
.net-chip:hover {
2025-12-21 18:45:40 +02:00
background: #cbd5e1;
border-color: #94a3b8;
2025-12-21 00:31:26 +02:00
}
/* DRAG & DROP OVERLAY */
body.drag-active::after {
content: '📂 Drop File to Import';
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(37, 99, 235, 0.85); /* Primary Color + Opacity */
color: white; font-size: 2rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
z-index: 99999; pointer-events: none;
backdrop-filter: blur(4px);
}
/* RESPONSIVE */
@media (max-width: 768px) {
/* STICKY EDITOR: Force Side-by-Side */
.add-form-container { flex-direction: column; }
.editor-visuals {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 5px;
}
.form-preview {
width: auto;
flex: 1;
height: 200px;
min-width: 0;
}
#inline-thumbs {
display: flex !important;
flex-direction: column;
width: 72px;
height: 200px;
border-left: 1px solid #e2e8f0;
flex-shrink: 0;
}
/* TABLE / LIST ITEMS */
thead { display: none; }
/* MOBILE SINGLE LINE ROW */
tbody tr {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem;
border: 1px solid var(--border);
margin-bottom: 0.25rem;
height: 2.2rem;
width: 100%;
box-sizing: border-box;
}
/* Reset Cell Styles */
td { display: block; border: none; padding: 0; }
td::before { display: none; }
/* 1. REF */
td:nth-child(1) {
flex: 0 0 4rem;
font-weight: 700;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 2. VALUE + TOOLS */
td:nth-child(2) {
flex: 1 1 auto;
min-width: 0;
max-width: 40%;
}
.val-cell { justify-content: flex-start; gap: 0.25rem; }
.val-text { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; }
.tool-group { display: flex; align-items: center; }
.tool-btn { width: 1.3rem; height: 1.3rem; }
/* 3. DESC */
td:nth-child(3) {
flex: 1 1 auto;
min-width: 0;
font-size: 0.8rem;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: right;
}
td:nth-child(4) { display: none; }
/* MAP TOOLBAR SCROLLING */
.map-toolbar {
max-width: calc(100vw - 1rem);
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
}
.map-toolbar::-webkit-scrollbar { display: none; }
.map-toolbar .custom-select-wrapper { min-width: 120px; width: auto; flex: 0 0 auto; }
2025-12-21 19:07:02 +02:00
#view-nets td:nth-child(1) {
flex: 1 1 35%; /* Give Net Name dynamic space */
min-width: 0; /* Allow shrinking if needed */
max-width: none; /* Uncap the width */
font-weight: normal;
}
#view-nets td:nth-child(2) {
flex: 1 1 50%; /* Give Nodes list the most space */
max-width: none; /* Remove the 40% cap from BOM */
white-space: normal; /* Allow chips to wrap lines */
}
#view-nets td:nth-child(3) {
flex: 0 0 auto; /* Delete button takes minimal space */
width: auto;
padding-left: 0.5rem;
}
2025-12-21 00:31:26 +02:00
}
@media (max-width: 900px) {
header { padding: 4px 2px; }
/* Force Single Line */
.top-bar {
gap: 2px;
flex-wrap: nowrap; /* CRITICAL: Prevents the 3-row break */
}
h1 { display: none; } /* Hide Title */
.nav-label { display: none; } /* Hide Labels */
.nav-group { gap: 2px; }
/* Dropdowns take all space */
.custom-select-wrapper { flex: 1; min-width: 0; }
.custom-select {
height: 1.7rem;
line-height: 1.7rem;
padding: 0 1rem 0 0.3rem;
font-size: 0.9rem;
text-overflow: ellipsis;
}
/* Hide Desktop Buttons, Show '...' */
.desktop-actions { display: none; }
.mobile-menu-btn {
display: block;
background: #f8fafc;
border: 1px solid #cbd5e1;
border-radius: 0.3rem;
font-size: 1.2rem;
color: #334155;
/* Dimensions in REM */
width: 1.7rem;
height: 1.7rem; /* Matches the dropdown height */
padding: 0;
cursor: pointer;
line-height: 1.6rem; /* Vertical alignment fix */
flex-shrink: 0;
text-align: center;
}
.mobile-menu-btn:active {
background: #e2e8f0;
border-color: #94a3b8;
}
.separator { margin: 0 1px; font-size: 1rem; }
/* 1. COMPACT TABS */
.tabs {
margin-top: 0; /* Remove top margin */
gap: 0.8rem; /* Reduce gap between tabs */
margin-bottom: 2px; /* Tiny breathing room before content */
}
.tab-btn {
padding: 0.1rem 0; /* Minimal vertical padding */
font-size: 0.85rem;
border-width: 2px; /* Keep border visible */
}
/* 2. COMPACT ACTION BAR (Sort + Buttons) */
.actions-bar {
gap: 0.2rem; /* Tighter spacing between buttons/dropdown */
margin-bottom: 0.2rem;
}
/* 3. FIX SORT DROPDOWN (Alignment & Size) */
.actions-bar .custom-select-wrapper {
min-width: 5rem; /* Allow it to be smaller */
}
.actions-bar .custom-select {
height: 1.6rem; /* Fixed height */
line-height: 1.6rem; /* CRITICAL: Centers text vertically */
padding: 0 1rem 0 0.3rem; /* 0 vertical padding, tight horizontal */
font-size: 0.8rem; /* Readable but compact */
}
/* Adjust arrow position for tighter dropdown */
.actions-bar .select-arrow {
right: 0.2rem;
font-size: 0.5rem; /* Smaller arrow */
}
/* Ensure buttons match the tight spacing */
.actions-bar button, .actions-bar .dropdown button {
padding: 0 0.5rem;
}
}
@media (max-width: 600px) {
/* 1. Modal Card: Tall & Centered */
.modal-content {
width: 96% !important;
height: 96dvh !important;
max-height: 96dvh !important;
border-radius: 0.5rem !important;
margin: auto;
display: flex;
flex-direction: column;
box-shadow: 0 10px 25px rgba(0,0,0,0.3);
position: relative;
}
/* 2. Header: Ultra Compact */
.modal-header {
/* Minimal vertical padding */
padding: 0.2rem 0.6rem !important;
/* Flex alignment handles centering, so we don't need thick padding */
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
min-height: 0 !important;
flex-shrink: 0;
border-bottom: 1px solid #e2e8f0;
}
.modal-header h3 {
font-size: 0.95rem !important;
margin: 0 !important;
line-height: 1.1 !important; /* Tight line height */
}
.close-btn {
font-size: 1.5rem !important;
line-height: 1 !important; /* Prevents button from being taller than text */
padding: 0 !important;
height: auto !important;
display: flex;
align-items: center;
}
/* 3. Iframe: Fill rest of space */
iframe {
border-radius: 0 0 0.5rem 0.5rem;
flex: 1;
min-height: 0;
height: 100%;
}
}
< / style >
< / head >
< body >
< header >
< div class = "top-bar" >
< h1 > PCB ReTrace< / h1 >
<!-- DEVICE SELECTOR -->
< div class = "nav-group" >
< span class = "nav-label" > Device< / span >
< div class = "custom-select-wrapper" >
< select id = "device-select" class = "custom-select" onchange = "switchDevice()" > < / select >
< span class = "select-arrow" > ▼< / span >
< / div >
<!-- Mobile Trigger -->
< button class = "mobile-menu-btn" onclick = "openMobileMenu('device-actions')" > ⋮< / button >
<!-- Collapsible Buttons -->
< div id = "device-actions" class = "desktop-actions" >
< button class = "icon-btn secondary" onclick = "createNewDevice()" title = "New Device" > + < / button >
< button class = "icon-btn secondary" onclick = "openDeviceSettings()" title = "Edit Device" > ✎< / button >
< input type = "file" id = "device-import-input" accept = ".zip,.json" style = "display:none" onchange = "handleImport(this)" >
2025-12-22 16:56:46 +02:00
< button class = "icon-btn secondary" onclick = "importDeviceFromURL()" title = "Import from URL" > 🌐< / button >
2025-12-21 00:31:26 +02:00
< button class = "icon-btn secondary" onclick = "document.getElementById('device-import-input').click()" title = "Import Device" > 📂< / button >
< button class = "icon-btn secondary" onclick = "exportDeviceZIP()" title = "Export Device" > 💾< / button >
< / div >
< / div >
< div class = "separator" > /< / div >
<!-- BOARD SELECTOR -->
< div class = "nav-group" >
< span class = "nav-label" > Board< / span >
< div class = "custom-select-wrapper" >
< select id = "bom-select" class = "custom-select" onchange = "switchBom()" > < / select >
< span class = "select-arrow" > ▼< / span >
< / div >
<!-- Mobile Trigger -->
< button class = "mobile-menu-btn" onclick = "openMobileMenu('board-actions')" > ⋮< / button >
<!-- Collapsible Buttons -->
< div id = "board-actions" class = "desktop-actions" >
< button class = "icon-btn secondary" onclick = "createNewBom()" title = "New Board" > + < / button >
< button class = "icon-btn secondary" onclick = "openBoardSettings()" title = "Board Settings" > ✎< / button >
< / div >
< / div >
2025-12-21 18:45:40 +02:00
< a href = "guide.html" target = "_blank" class = "icon-btn secondary" style = "text-decoration:none; margin-left:5px; display:flex; align-items:center; justify-content:center;" title = "Help / Guide" > ?< / a >
2025-12-21 00:31:26 +02:00
< / div >
< div class = "tabs" >
< div id = "tab-list" class = "tab-btn active" onclick = "switchView('list')" > 📋 BOM< / div >
2026-04-24 17:28:59 +03:00
< div id = "tab-map" class = "tab-btn" onclick = "switchView('map')" > 🖼️ Images< / div >
2025-12-21 18:45:40 +02:00
< div id = "tab-nets" class = "tab-btn" onclick = "switchView('nets')" > 🕸️ Nets< / div >
< div id = "tab-inspect" class = "tab-btn" onclick = "switchView('inspect')" > 🔍 Inspect< / div >
2026-04-24 17:28:59 +03:00
< div id = "tab-schema" class = "tab-btn" onclick = "switchView('schema')" >
< svg width = "16" height = "16" viewBox = "0 0 16 16" style = "vertical-align: middle; margin-right: 4px;" >
< line x1 = "2" y1 = "11" x2 = "14" y2 = "11" stroke = "currentColor" stroke-width = "1.5" / >
< line x1 = "8" y1 = "1" x2 = "8" y2 = "11" stroke = "currentColor" stroke-width = "1.5" / >
< circle cx = "8" cy = "11" r = "2" fill = "currentColor" / >
< / svg >
Schema
< / div >
2025-12-21 00:31:26 +02:00
< / div >
< / header >
< main class = "main-content" >
<!-- 1. BOM LIST VIEW (Existing) -->
< div id = "view-list" class = "view-section active" >
< div class = "list-fixed-top" >
< div class = "actions-bar" >
< div class = "custom-select-wrapper" style = "flex:0 0 auto;" >
< select id = "sort-select" class = "custom-select" onchange = "changeSortMode()" >
< option value = "none" > No Sorting< / option >
< option value = "std" > Sort: Type< / option >
< option value = "num" > Sort: Num< / option >
< / select >
< span class = "select-arrow" > ▼< / span >
< / div >
< input type = "file" id = "import-input" accept = ".csv,.json,.zip" style = "display:none" onchange = "handleImport(this)" >
< button class = "secondary" onclick = "document.getElementById('import-input').click()" title = "Import" > 📂< / button >
< div class = "dropdown" >
< button class = "secondary" onclick = "toggleExport(event)" title = "Export" > 💾▼< / button >
< div id = "export-dropdown" class = "dropdown-content" >
< a onclick = "exportCSV()" > CSV< / a >
< a onclick = "exportZIP()" > ZIP (Board)< / a >
< / div >
< / div >
< button class = "secondary danger" onclick = "clearCurrentBom()" title = "Clear All" > 🗑️ < / button >
< / div >
< div class = "add-form-container" id = "add-form" >
< div class = "editor-visuals" >
< div class = "form-preview empty" id = "form-preview" >
< canvas id = "preview-canvas" width = "400" height = "400" > < / canvas >
< div class = "preview-overlay" id = "zoom-level" > 2x< / div >
< / div >
< div id = "inline-thumbs" > < / div >
< / div >
< div class = "form-inputs" >
< div class = "form-row" >
< div class = "input-group" style = "flex: 0 0 35%;" >
< div class = "input-group-label" > Ref< / div >
< input type = "text" id = "inp-label" placeholder = "R1" oninput = "checkToolHint(this.value)" >
< input type = "hidden" id = "inp-id" >
< input type = "hidden" id = "inp-x" > < input type = "hidden" id = "inp-y" > < input type = "hidden" id = "inp-img-id" >
< / div >
< div class = "input-group" style = "flex: 1;" >
< div class = "input-group-label" > Val< / div >
< input type = "text" id = "inp-value" placeholder = "10k" >
< div id = "new-part-tool-container" > < / div >
< / div >
< / div >
< div class = "form-row" >
< div class = "input-group" style = "flex: 1;" >
< div class = "input-group-label" > Desc< / div >
< input type = "text" id = "inp-desc" placeholder = "0603, etc..." >
< / div >
< button class = "danger btn-save-icon" onclick = "deleteCurrentPart()" title = "Delete" > 🗑️< / button >
< button class = "primary btn-save-icon" onclick = "addPart()" title = "Add / Update" > 💾< / button >
< / div >
< / div >
< / div >
< / div >
< div class = "list-scroll-area" >
< div class = "table-container" >
< table >
< thead > < tr > < th width = "15%" > Ref< / th > < th width = "35%" > Value< / th > < th width = "50%" > Desc< / th > < / tr > < / thead >
< tbody id = "bom-body" > < / tbody >
< / table >
< / div >
< / div >
< / div >
<!-- 2. IMAGE MAP VIEW (Existing) -->
< div id = "view-map" class = "view-section" >
< div class = "map-toolbar" >
< div class = "custom-select-wrapper" >
< select id = "image-select" class = "custom-select" onchange = "switchImage()" >
< option value = "" disabled selected > No Images< / option >
< / select >
< span class = "select-arrow" > ▼< / span >
< / div >
< input
type="file"
id="import-file-input"
accept="image/jpeg, image/png, image/webp, image/avif, image/heic, image/heif, image/bmp, .jpg, .jpeg, .png, .webp, .avif, .heic, .heif, .bmp"
style="display:none"
onchange="ImageImporter.handleFileSelect(this)"
>
< button class = "icon-btn secondary" onclick = "document.getElementById('import-file-input').click()" title = "Upload Image" > 📁< / button >
< button class = "icon-btn secondary" onclick = "ImageImporter.openCamera()" title = "Camera" > 📷< / button >
< button class = "icon-btn secondary" onclick = "openImageSettings()" title = "Image Settings" > ✎< / button >
< div style = "width:1px; height:20px; background:#cbd5e1; margin:0 0.5rem;" > < / div >
< button class = "secondary sm-btn" onclick = "fitMap()" > Fit< / button >
< / div >
< div class = "map-viewport" id = "map-viewport" >
< div class = "map-content" id = "map-content" >
< div id = "map-placeholder" class = "empty-state" >
< h3 > No Image Selected< / h3 >
< p > Add images to start positioning components.< / p >
< / div >
< / div >
< / div >
< / div >
<!-- 3. NETS LIST VIEW -->
< div id = "view-nets" class = "view-section" >
< div class = "list-fixed-top" >
< div class = "actions-bar" >
< button class = "secondary" onclick = "exportKiCad()" title = "Export KiCad" > 💾 KiCad< / button >
2026-04-24 17:28:59 +03:00
< button class = "secondary" onclick = "exportSpice()" title = "Export SPICE" > 💾 SPICE< / button >
2025-12-21 00:31:26 +02:00
< / div >
< / div >
< div class = "list-scroll-area" >
< div class = "table-container" >
< table >
< thead > < tr > < th width = "30%" > Net Name< / th > < th width = "60%" > Nodes (Click to Edit)< / th > < th width = "10%" > < / th > < / tr > < / thead >
< tbody id = "nets-body" > < / tbody >
< / table >
< / div >
< / div >
< / div >
<!-- 4. INSPECT VIEW (Fixed Layout & Toolbar) -->
< div id = "view-inspect" class = "view-section" >
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
<!-- Single Floating Toolbar Container -->
< div id = "inspect-toolbar" style = "position:absolute; top:0.5rem; left:0.5rem; right:0.5rem; z-index:100; display:flex; gap:0.5rem; align-items:center; pointer-events:none;" >
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
<!-- 1. Layers Dropdown -->
< div style = "pointer-events:auto; padding:0 0.5rem; background:rgba(255,255,255,0.95); box-shadow:0 2px 10px rgba(0,0,0,0.3); border-radius:4px; height:2rem; display:flex; align-items:center;" >
< details id = "inspect-layers-details" style = "position:relative;" >
< summary style = "cursor:pointer; color:#334155; font-weight:700; font-size:0.8rem; outline:none; white-space:nowrap; list-style:none;" > ☰ Layers ▾< / summary >
< div id = "inspect-layers" style = "position:absolute; top:100%; left:-0.5rem; margin-top:0.5rem; background:white; padding:10px; border-radius:4px; box-shadow:0 4px 12px rgba(0,0,0,0.2); display:flex; flex-direction:column; gap:5px; min-width:200px;" > < / div >
< / details >
< / div >
<!-- 2. Active Net Info (Hidden by default) -->
< div id = "inspect-active-net" style = "pointer-events:auto; background:rgba(30, 41, 59, 0.95); padding:0 0.8rem; border-radius:4px; border:1px solid #475569; display:none; color:white; height:2rem; align-items:center; box-shadow:0 2px 5px rgba(0,0,0,0.5);" >
<!-- Injected by JS -->
< / div >
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
<!-- 3. New Net Button -->
< button class = "primary sm-btn" style = "pointer-events:auto; box-shadow:0 2px 5px rgba(0,0,0,0.5); height:2rem;" onclick = "inspector.startNewNet()" > + New Net< / button >
< / div >
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
<!-- Full Screen Grid -->
< div id = "inspect-grid" >
< div style = "display:flex; align-items:center; justify-content:center; color:#64748b; height:100%;" > Select layers to inspect< / div >
< / div >
< / div >
2026-04-24 17:28:59 +03:00
<!-- 5. SCHEMA VIEW — Schematic ReTrace (lazy - loaded iframe) -->
< div id = "view-schema" class = "view-section" style = "padding:0; overflow:hidden; height:100%;" >
< iframe
id="schema-frame"
style="width:100%; height:100%; border:none; display:block;"
src=""
>< / iframe >
< / div >
2025-12-21 00:31:26 +02:00
< / main >
2026-04-24 17:28:59 +03:00
< footer class = "app-footer" > Copyright © 2025—2026 Taras Greben — < a href = "https://pcb.etaras.com" > pcb.eTaras.com< / a > < / footer >
2025-12-21 00:31:26 +02:00
<!-- COMPONENT TOOL MODAL -->
< div style = "display:none" id = "tool-modal" class = "modal-overlay" > < div class = "modal-content" > < div class = "modal-header" > < h3 style = "margin:0" id = "tool-title" > Component Tool< / h3 > < button class = "close-btn" onclick = "closeTool()" > × < / button > < / div > < div id = "tool-selector" > < h4 style = "color:#64748b; margin-top:0" > Choose:< / h4 > < div class = "selector-grid" id = "selector-grid" > < / div > < / div > < iframe id = "tool-frame" src = "" > < / iframe > < / div > < / div >
<!-- BOARD SETTINGS MODAL -->
< div style = "display:none" id = "board-settings-modal" class = "modal-overlay" >
< div class = "modal-content" style = "height:auto; max-width:400px; max-height:80vh; overflow-y:auto;" >
< div class = "modal-header" >
< h3 style = "margin:0" > Board Settings< / h3 >
< button class = "close-btn" onclick = "document.getElementById('board-settings-modal').style.display='none'" > × < / button >
< / div >
< div id = "board-settings-form" >
< div > < label > Board Name< / label > < input type = "text" id = "edit-board-name" > < / div >
< div > < label > Section (Group)< / label > < input type = "text" id = "edit-board-section" placeholder = "e.g. Power Supply" > < / div >
< div > < label > Parent Device< / label > < select id = "edit-board-device" class = "custom-select" > < / select > < / div >
< div style = "margin-top:1rem; display:flex; justify-content:flex-end;" >
< button class = "danger" style = "padding:0.6rem 1.2rem;" onclick = "deleteBom()" > Delete Board< / button >
< button class = "primary" style = "padding:0.6rem 1.2rem;" onclick = "saveBoardSettings()" > Save Changes< / button >
< / div >
< / div >
< / div >
< / div >
<!-- DEVICE SETTINGS MODAL -->
< div style = "display:none" id = "device-settings-modal" class = "modal-overlay" >
< div class = "modal-content" style = "height:auto; max-width:400px; max-height:80vh; overflow-y:auto;" >
< div class = "modal-header" >
< h3 style = "margin:0" > Device Settings< / h3 >
< button class = "close-btn" onclick = "document.getElementById('device-settings-modal').style.display='none'" > × < / button >
< / div >
< div id = "device-settings-form" >
< div > < label > Device Name< / label > < input type = "text" id = "edit-device-name" > < / div >
< div style = "margin-top:1rem; display:flex; justify-content:space-between; align-items:center;" >
< button class = "danger" style = "padding:0.6rem 1.2rem;" onclick = "deleteCurrentDevice()" > Delete Device< / button >
< button class = "primary" style = "padding:0.6rem 1.2rem;" onclick = "saveDeviceSettings()" > Save Changes< / button >
< / div >
< / div >
< / div >
< / div >
<!-- CONNECTIONS LIST MODAL -->
< div style = "display:none" id = "connections-modal" class = "modal-overlay" >
< div class = "modal-content" style = "height:auto; max-width:500px; max-height:80vh;" >
< div class = "modal-header" >
< h3 style = "margin:0" > Image Connections< / h3 >
< button class = "close-btn" onclick = "document.getElementById('connections-modal').style.display='none'" > × < / button >
< / div >
< div style = "padding:1rem;" >
< p style = "font-size:0.9rem; color:#666; margin-top:0;" > Stitch < b > < span id = "conn-src-name" > < / span > < / b > with other images.< / p >
< div id = "conn-list" style = "display:flex; flex-direction:column; gap:0.5rem; max-height:300px; overflow-y:auto;" > < / div >
< div style = "margin-top:1rem; padding-top:1rem; border-top:1px solid #eee; text-align:right;" >
< button class = "primary" onclick = "runAutoCVBatch()" > ⚡ Run Auto-CV on All< / button >
< / div >
< / div >
< / div >
< / div >
<!-- STITCH EDITOR MODAL -->
< div style = "display:none" id = "stitch-modal" class = "modal-overlay" >
< div class = "modal-content" >
< div class = "modal-header" >
< h3 id = "stitch-title" style = "margin:0" > Stitch Images< / h3 >
< button class = "close-btn" onclick = "document.getElementById('stitch-modal').style.display='none'" > × < / button >
< / div >
< div class = "stitch-split" >
< div class = "stitch-pane" > < canvas id = "stitch-canvas-src" > < / canvas > < / div >
< div class = "stitch-pane" > < canvas id = "stitch-canvas-dst" > < / canvas > < / div >
< / div >
< div class = "stitch-toolbar" style = "display:flex; align-items:center; gap:8px;" >
<!-- JS Injects Flip Button on Left -->
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
< div style = "flex:1; text-align:center;" >
< button class = "secondary sm-btn" onclick = "stitchEditor.setGrid(2)" title = "Set 4 Points (Corners)" > Grid 2x2< / button >
< button class = "secondary sm-btn" onclick = "stitchEditor.setGrid(3)" title = "Set 9 Points (Recommended)" > Grid 3x3< / button >
< button class = "secondary sm-btn" onclick = "stitchEditor.clearPoints()" title = "Remove All Points" > Clear< / button >
< / div >
< button onclick = "stitchEditor.refresh()" > Reset View< / button >
< button class = "primary" onclick = "stitchEditor.save()" > Save Match< / button >
< / div >
< / div >
< / div >
<!-- IMAGE IMPORT MODAL -->
< div style = "display:none" id = "import-modal" class = "modal-overlay" >
< div class = "modal-content" >
< div class = "modal-header" >
< h3 style = "margin:0" > Import Image< / h3 >
< button class = "close-btn" onclick = "ImageImporter.close()" title = "Close" > × < / button >
< / div >
<!-- Editor / Camera Area -->
< div class = "import-viewport" >
< canvas id = "import-canvas" > < / canvas >
< video id = "import-video" style = "display:none" playsinline autoplay > < / video >
<!-- Camera UI Overlay -->
< div id = "camera-overlay" style = "display:none; position:absolute; inset:0; pointer-events:none;" >
< button id = "btn-flash" style = "display:none; pointer-events:auto; position:absolute; top:20px; right:20px; background:rgba(0,0,0,0.5); border:1px solid white; color:white; border-radius:50%; width:40px; height:40px; font-size:18px;" onclick = "ImageImporter.toggleFlash()" title = "Toggle Flash" > ⚡< / button >
< div style = "position:absolute; bottom:20px; left:0; right:0; text-align:center; pointer-events:auto;" >
< button class = "primary" style = "border-radius:50%; width:60px; height:60px; font-size:24px; box-shadow:0 0 10px rgba(0,0,0,0.5); border:3px solid white;" onclick = "ImageImporter.captureVideo()" title = "Take Photo" > ◉< / button >
< / div >
< / div >
<!-- Status Message -->
< div id = "camera-msg" style = "display:none; position:absolute; top:50%; left:50%; transform:translate(-50%, -50%); color:white; text-align:center;" >
< p > Starting Camera...< / p >
< / div >
< / div >
<!-- Toolbar -->
< div class = "actions-bar" style = "display:flex; justify-content:center; gap:8px; flex-wrap:wrap; align-items:center;" >
<!-- Left Group: Edit Tools -->
< button id = "btn-retake" class = "secondary" style = "display:none; border-color:#94a3b8;" onclick = "ImageImporter.openCamera()" title = "Return to Camera" > ← Retake< / button >
< button id = "btn-crop-toggle" class = "secondary" onclick = "ImageImporter.toggleCropMode()" title = "Toggle Crop Mode" > ✂ Crop< / button >
< button class = "secondary" onclick = "ImageImporter.resetView()" title = "Fit Image to Screen" > Fit< / button >
< button id = "btn-reset" class = "secondary" onclick = "ImageImporter.resetEdits()" title = "Reset Rotation & Crop" > Reset< / button >
< div style = "width:1px; height:20px; background:#475569; margin:0 2px;" > < / div >
<!-- Right Group: Rotation (Wrapped to prevent breaking) -->
< div style = "display:flex; gap:2px;" >
< button class = "secondary" onclick = "ImageImporter.rotate(-90)" title = "Rotate Counter-Clockwise" > ↺< / button >
< button class = "secondary" onclick = "ImageImporter.rotate(180)" title = "Rotate 180°" > ⇅< / button >
< button class = "secondary" onclick = "ImageImporter.rotate(90)" title = "Rotate Clockwise" > ↻< / button >
< / div >
<!-- Input & Save -->
< div style = "display:flex; gap:4px; align-items:center;" >
< input type = "text" id = "import-name" placeholder = "Image Name" title = "Enter Image Name" style = "width:100px; background:#334155; border:1px solid #475569; color:white; border-radius:4px; padding:0 5px; height:1.6rem;" >
< button class = "primary" onclick = "ImageImporter.save()" title = "Save Image to Board" > Save< / button >
< / div >
< / div >
< / div >
< / div >
<!-- GENERIC CONFIRMATION MODAL -->
< div style = "display:none; z-index:1100;" id = "confirmation-modal" class = "modal-overlay" >
< div class = "modal-content" style = "height:auto; max-width:400px; background:#fff; border:1px solid #cbd5e1;" >
< div class = "modal-header" >
< h3 style = "margin:0" > Confirm Action< / h3 >
< button class = "close-btn" onclick = "document.getElementById('confirmation-modal').style.display='none'" > × < / button >
< / div >
< div style = "padding:1.5rem;" >
< p id = "confirm-msg" style = "margin-top:0; color:#334155; white-space: pre-wrap;" > < / p >
< div style = "margin-top:1.5rem; display:flex; justify-content:flex-end; gap:10px;" >
< button id = "confirm-btn-cancel" class = "secondary" style = "border-color:#94a3b8;" > Cancel< / button >
< button id = "confirm-btn-ok" class = "danger" > Confirm Delete< / button >
< / div >
< / div >
< / div >
< / div >
<!-- IMAGE SETTINGS MODAL -->
< div style = "display:none" id = "image-settings-modal" class = "modal-overlay" >
< div class = "modal-content" style = "height:auto; max-width:400px;" >
< div class = "modal-header" >
< h3 style = "margin:0" > Image Settings< / h3 >
< button class = "close-btn" onclick = "document.getElementById('image-settings-modal').style.display='none'" > × < / button >
< / div >
< div style = "padding:1.5rem; display:flex; flex-direction:column; gap:1rem;" >
<!-- Rename Section -->
< div >
< label style = "font-size:0.8rem; font-weight:700; color:#64748b; text-transform:uppercase; margin-bottom:0.2rem; display:block;" > Image Name< / label >
< input type = "text" id = "edit-image-name" style = "width:100%; padding:0.6rem; border:1px solid #cbd5e1; border-radius:0.3rem; font-size:1rem;" >
< / div >
<!-- Actions Section -->
< button class = "secondary" style = "justify-content:space-between; padding:0.8rem;" onclick = "openConnectionsModal()" >
< span > 🔗 Stitch / Connections< / span >
< span > →< / span >
< / button >
< div style = "margin-top:0.5rem; display:flex; justify-content:space-between; align-items:center;" >
< button class = "danger" style = "padding:0.6rem 1.2rem;" onclick = "deleteCurrentImage()" > Delete Image< / button >
< button class = "primary" style = "padding:0.6rem 1.2rem;" onclick = "saveImageSettings()" > Save< / button >
< / div >
< / div >
< / div >
< / div >
2025-12-21 18:45:40 +02:00
<!-- GENERIC INPUT MODAL -->
< div style = "display:none; z-index:1150;" id = "generic-input-modal" class = "modal-overlay" >
< div class = "modal-content" style = "height:auto; max-width:300px; background:#fff; border:1px solid #cbd5e1;" >
2025-12-26 14:55:08 +02:00
< div class = "modal-header" style = "display:flex; align-items:center; justify-content:space-between;" >
< div style = "display:flex; align-items:center; gap:10px;" >
< h3 style = "margin:0" id = "gim-title" > Input< / h3 >
< button id = "gim-help-toggle" class = "secondary sm-btn" style = "display:none; padding:0 6px; font-size:0.75rem; height:20px; line-height:1;" > ?< / button >
< / div >
2025-12-21 18:45:40 +02:00
< button class = "close-btn" onclick = "document.getElementById('generic-input-modal').style.display='none'" > × < / button >
< / div >
< div style = "padding:1.5rem; display:flex; flex-direction:column; gap:1rem;" >
< div >
< label id = "gim-label" style = "font-size:0.8rem; font-weight:700; color:#64748b; text-transform:uppercase; margin-bottom:0.2rem; display:block;" > Value< / label >
2025-12-21 19:07:02 +02:00
< input type = "text" id = "gim-input" style = "width:100%; height:auto; padding:0.5rem; border:1px solid #cbd5e1; border-radius:0.3rem; font-size:1rem; box-sizing:border-box;" >
2025-12-26 14:55:08 +02:00
<!-- NEW HELP CONTAINER -->
< div id = "gim-help-content" style = "display:none; margin-top:0.5rem; padding:0.5rem; background:#f0f9ff; border:1px solid #bae6fd; border-radius:4px; font-size:0.8rem; color:#0c4a6e;" > < / div >
2025-12-21 18:45:40 +02:00
< / div >
2025-12-26 14:55:08 +02:00
<!-- (Footer remains the same) -->
2025-12-21 18:45:40 +02:00
< div style = "display:flex; justify-content:space-between; gap:10px;" id = "gim-footer" >
< button id = "gim-extra-btn" class = "danger" style = "display:none; padding:0.6rem 1rem; margin-right:auto;" > < / button >
< button id = "gim-cancel-btn" class = "secondary" style = "padding:0.6rem 1rem;" > Cancel< / button >
< button id = "gim-ok-btn" class = "primary" style = "padding:0.6rem 1rem;" > OK< / button >
< / div >
< / div >
< / div >
< / div >
2025-12-21 00:31:26 +02:00
<!-- MOBILE ACTION SHEET -->
< div style = "display:none; align-items:flex-end;" id = "mobile-menu-modal" class = "modal-overlay" onclick = "this.style.display='none'" >
< div class = "modal-content" style = "width:100%; max-width:100%; border-radius:1rem 1rem 0 0; margin:0; animation: slideUp 0.2s ease-out;" onclick = "event.stopPropagation()" >
< div class = "modal-header" >
< h3 style = "margin:0; font-size:1rem;" > Actions< / h3 >
< button class = "close-btn" onclick = "document.getElementById('mobile-menu-modal').style.display='none'" > × < / button >
< / div >
<!-- CHANGED: Switched from Grid to Flex - Wrap for better spacing on narrow screens -->
< div id = "mobile-menu-content" style = "padding:2rem 1rem; display:flex; flex-wrap:wrap; gap:1.5rem; justify-content:center;" >
<!-- Buttons injected here -->
< / div >
< / div >
< / div >
< style >
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
/* MODIFIED: Column Layout for List View */
#mobile-menu-content {
padding: 1.5rem 1rem 3rem 1rem; /* Extra bottom padding for safe area */
display: flex;
flex-direction: column; /* Vertical List */
gap: 0.8rem;
}
/* NEW: Row Container */
.mobile-action-row {
display: flex;
align-items: center;
gap: 1rem;
background: #fff;
border: 1px solid #e2e8f0;
padding: 0.8rem 1rem;
border-radius: 0.8rem;
cursor: pointer;
transition: background 0.1s;
box-shadow: 0 2px 4px rgba(0,0,0,0.02);
}
.mobile-action-row:active {
background: #f1f5f9;
transform: scale(0.99);
}
/* Text Style */
.mobile-action-row span {
font-size: 1.1rem;
font-weight: 600;
color: #334155;
}
/* Reset/Style the Cloned Buttons inside the list */
.mobile-action-row button,
.mobile-action-row label {
width: 2.5rem !important;
height: 2.5rem !important;
font-size: 1.2rem !important;
border-radius: 0.5rem !important;
background: #f8fafc !important;
border: 1px solid #cbd5e1 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
margin: 0 !important;
box-shadow: none !important;
}
< / style >
< script >
function openMobileMenu(sourceId) {
const source = document.getElementById(sourceId);
const target = document.getElementById('mobile-menu-content');
target.innerHTML = '';
Array.from(source.children).forEach(child => {
// Filter: Only Buttons or Labels, and ignore hidden elements (like the file input itself)
if ((child.tagName === 'BUTTON' || child.tagName === 'LABEL') & & child.style.display !== 'none') {
// 1. Create Row Container
const row = document.createElement('div');
row.className = 'mobile-action-row';
row.onclick = (e) => {
// Trigger the original action
child.click();
document.getElementById('mobile-menu-modal').style.display = 'none';
};
// 2. Clone the Icon Button
const clone = child.cloneNode(true);
// Remove ID to avoid duplicates
clone.removeAttribute('id');
// Prevent double-triggering since the row handles the click
clone.onclick = (e) => e.stopPropagation();
// Make the clone purely visual/clickable via the row
clone.style.pointerEvents = 'none';
// 3. Create Label from Title
const label = document.createElement('span');
label.innerText = child.title || "Action";
// 4. Assemble
row.appendChild(clone);
row.appendChild(label);
target.appendChild(row);
}
});
document.getElementById('mobile-menu-modal').style.display = 'flex';
}
< / script >
< script >
/**
* IMAGE IMPORTER & PROCESSOR
* Handles File/Camera Input -> Clean Canvas -> Crop -> JPEG Blob -> Database
*/
const ImageImporter = {
modal: document.getElementById('import-modal'),
canvas: document.getElementById('import-canvas'),
ctx: null,
video: document.getElementById('import-video'),
nameInput: document.getElementById('import-name'),
flashBtn: document.getElementById('btn-flash'),
retakeBtn: document.getElementById('btn-retake'),
cropBtn: document.getElementById('btn-crop-toggle'),
stream: null,
track: null,
sourceImage: null,
currentRotation: 0,
isCameraSession: false,
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
// View State (Zoom/Pan)
view: { scale: 1, x: 0, y: 0 },
lastDist: 0, // For pinch zoom
// Crop/Drag State
isCropping: false,
isDragging: false,
isPanning: false,
cropRect: null,
dragStart: {x:0, y:0},
panStart: {x:0, y:0},
initListeners: function() {
if(this.listenersAdded) return;
const start = (e) => this.handleDragStart(e);
const move = (e) => this.handleDragMove(e);
const end = () => this.handleDragEnd();
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
this.canvas.addEventListener('mousedown', start);
this.canvas.addEventListener('touchstart', start, {passive: false});
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
window.addEventListener('mousemove', move);
window.addEventListener('touchmove', move, {passive: false});
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
window.addEventListener('mouseup', end);
window.addEventListener('touchend', end);
// Zoom Wheel (Desktop)
this.canvas.addEventListener('wheel', e => {
e.preventDefault();
// Zoom towards mouse pointer could be added, but center zoom is safer for simple preview
const d = e.deltaY > 0 ? 0.9 : 1.1;
this.zoom(d);
}, {passive: false});
this.listenersAdded = true;
},
zoom: function(factor) {
const newScale = Math.max(0.1, Math.min(10, this.view.scale * factor));
this.view.scale = newScale;
this.applyView();
},
applyView: function() {
// Apply CSS Transform to visual canvas
this.canvas.style.transform = `translate(${this.view.x}px, ${this.view.y}px) scale(${this.view.scale})`;
},
// --- NEW: FIT BUTTON ACTION ---
resetView: function() {
this.view = { scale: 1, x: 0, y: 0 };
this.applyView();
},
handleFileSelect: function(input) {
if (input.files & & input.files[0]) {
this.isCameraSession = false;
const file = input.files[0];
this.nameInput.value = file.name.replace(/\.[^/.]+$/, "");
const reader = new FileReader();
reader.onload = (e) => this.loadImage(e.target.result);
reader.readAsDataURL(file);
input.value = '';
}
},
openCamera: async function() {
this.isCameraSession = true;
this.openModal('camera');
const overlay = document.getElementById('camera-overlay');
const msg = document.getElementById('camera-msg');
msg.style.display = 'block'; msg.innerHTML = "Requesting Camera Access...";
overlay.style.display = 'none'; this.flashBtn.style.display = 'none';
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
msg.innerHTML = "Camera API not supported.< br > (HTTPS required)"; return;
}
try {
this.stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 4096 }, height: { ideal: 2160 }, focusMode: { ideal: 'continuous' } },
audio: false
});
this.video.srcObject = this.stream;
this.video.onloadedmetadata = () => {
msg.style.display = 'none'; overlay.style.display = 'block';
this.track = this.stream.getVideoTracks()[0];
const caps = this.track.getCapabilities ? this.track.getCapabilities() : {};
const supConstraints = navigator.mediaDevices.getSupportedConstraints();
if (('torch' in caps) || supConstraints.torch) {
this.flashBtn.style.display = 'block';
this.flashBtn.style.background = 'rgba(0,0,0,0.5)';
this.flashBtn.dataset.on = "false";
}
};
} catch (err) { console.error(err); msg.innerHTML = "Error accessing camera:< br > " + err.message; }
},
toggleFlash: function() {
if (!this.track) return;
const newState = !(this.flashBtn.dataset.on === "true");
const promise = this.track.applyConstraints({ advanced: [{ torch: newState }] });
if(promise) {
promise.then(() => {
this.flashBtn.dataset.on = newState;
this.flashBtn.style.background = newState ? '#eab308' : 'rgba(0,0,0,0.5)';
}).catch(e => {
const oldBg = this.flashBtn.style.background;
this.flashBtn.style.background = '#ef4444';
setTimeout(() => this.flashBtn.style.background = oldBg, 500);
});
}
},
captureVideo: function() {
if (!this.video.videoWidth) return;
this.canvas.width = this.video.videoWidth;
this.canvas.height = this.video.videoHeight;
this.canvas.getContext('2d').drawImage(this.video, 0, 0);
this.stopStream();
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
const now = new Date();
const ts = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
this.nameInput.value = `IMG_${ts}`;
this.loadImage(this.canvas.toDataURL('image/jpeg'));
},
openModal: function(mode = 'edit') {
this.modal.style.display = 'flex';
this.ctx = this.canvas.getContext('2d');
if(mode === 'edit') {
this.canvas.style.display = 'block';
this.video.style.display = 'none';
document.getElementById('camera-overlay').style.display = 'none';
document.getElementById('camera-msg').style.display = 'none';
this.retakeBtn.style.display = this.isCameraSession ? 'block' : 'none';
this.initListeners();
} else if (mode === 'camera') {
this.canvas.style.display = 'none';
this.video.style.display = 'block';
this.retakeBtn.style.display = 'none';
}
},
stopStream: function() {
if (this.stream) {
this.stream.getTracks().forEach(t => { try { t.applyConstraints({ advanced: [{ torch: false }] }); } catch(e){} t.stop(); });
this.stream = null; this.track = null;
}
},
close: function() {
this.modal.style.display = 'none';
this.stopStream();
this.sourceImage = null;
this.cropRect = null;
this.resetView(); // Reset zoom when closing
},
loadImage: function(dataUrl) {
this.openModal('edit');
const img = new Image();
img.onload = () => {
this.sourceImage = img;
this.resetEdits(); // Full Reset (View + Rotation + Crop)
};
img.onerror = () => { alert("Error: Image format not supported."); this.close(); };
img.src = dataUrl;
},
resetEdits: function() {
this.currentRotation = 0;
this.cropRect = null;
this.resetView();
this.render();
},
rotate: function(deg) {
this.currentRotation = (this.currentRotation + deg) % 360;
this.cropRect = null;
this.render();
},
toggleCropMode: function() {
this.isCropping = !this.isCropping;
this.cropBtn.style.background = this.isCropping ? '#2563eb' : '';
this.cropBtn.style.color = this.isCropping ? 'white' : '';
// Visual feedback: Crosshair for Cropping, Hand for Panning
this.canvas.style.cursor = this.isCropping ? 'crosshair' : 'grab';
},
getCanvasCoords: function(e) {
const rect = this.canvas.getBoundingClientRect();
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
return { x: (cx - rect.left) * scaleX, y: (cy - rect.top) * scaleY };
},
handleDragStart: function(e) {
// Pinch Start (Mobile Zoom)
if (e.type === 'touchstart' & & e.touches.length > 1) {
2025-12-21 18:45:40 +02:00
this.isDragging = false;
2025-12-21 00:31:26 +02:00
this.isPanning = false;
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
this.lastDist = Math.hypot(dx, dy);
return;
}
if (e.type === 'touchstart') e.preventDefault();
if (this.isCropping) {
this.isDragging = true;
this.dragStart = this.getCanvasCoords(e);
this.cropRect = { x: this.dragStart.x, y: this.dragStart.y, w: 0, h: 0 };
this.render();
} else {
// Pan Start
this.isPanning = true;
this.canvas.style.cursor = 'grabbing';
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
this.panStart = { x: cx - this.view.x, y: cy - this.view.y };
}
},
handleDragMove: function(e) {
// Pinch Move
if (e.touches & & e.touches.length > 1) {
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const curDist = Math.hypot(dx, dy);
if (this.lastDist > 0) {
const diff = curDist / this.lastDist;
const factor = 1 + (diff - 1) * 0.5; // Soften zoom speed
this.zoom(factor);
}
this.lastDist = curDist;
return;
}
if (e.type === 'touchmove') e.preventDefault();
if (this.isCropping & & this.isDragging) {
const cur = this.getCanvasCoords(e);
const w = cur.x - this.dragStart.x;
const h = cur.y - this.dragStart.y;
this.cropRect = {
x: w < 0 ? cur . x : this . dragStart . x ,
y: h < 0 ? cur . y : this . dragStart . y ,
w: Math.abs(w),
h: Math.abs(h)
};
this.render();
} else if (this.isPanning) {
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
this.view.x = cx - this.panStart.x;
this.view.y = cy - this.panStart.y;
this.applyView();
}
},
handleDragEnd: function() {
this.isDragging = false;
this.isPanning = false;
this.lastDist = 0;
if(!this.isCropping) this.canvas.style.cursor = 'grab';
if(this.cropRect & & (this.cropRect.w < 10 | | this . cropRect . h < 10 ) ) {
this.cropRect = null;
this.render();
}
},
render: function() {
if (!this.sourceImage) return;
const width = this.sourceImage.width;
const height = this.sourceImage.height;
const angle = this.currentRotation * Math.PI / 180;
const sin = Math.abs(Math.sin(angle));
const cos = Math.abs(Math.cos(angle));
const newWidth = width * cos + height * sin;
const newHeight = width * sin + height * cos;
this.canvas.width = newWidth;
this.canvas.height = newHeight;
this.ctx.clearRect(0, 0, newWidth, newHeight);
this.ctx.save();
this.ctx.translate(newWidth / 2, newHeight / 2);
this.ctx.rotate(angle);
this.ctx.drawImage(this.sourceImage, -width / 2, -height / 2);
this.ctx.restore();
if (this.cropRect) {
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
this.ctx.fillRect(0, 0, newWidth, newHeight);
this.ctx.clearRect(this.cropRect.x, this.cropRect.y, this.cropRect.w, this.cropRect.h);
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(this.cropRect.x, this.cropRect.y, this.cropRect.w, this.cropRect.h);
this.ctx.clip();
this.ctx.translate(newWidth / 2, newHeight / 2);
this.ctx.rotate(angle);
this.ctx.drawImage(this.sourceImage, -width / 2, -height / 2);
this.ctx.restore();
this.ctx.strokeStyle = '#ef4444';
this.ctx.lineWidth = 3;
this.ctx.strokeRect(this.cropRect.x, this.cropRect.y, this.cropRect.w, this.cropRect.h);
}
},
save: function() {
if (!this.sourceImage) return;
let name = this.nameInput.value.trim() || "New Image";
name = name.replace(/\.(jpg|jpeg|png)$/i, "");
const rect = this.cropRect;
2025-12-21 18:45:40 +02:00
2025-12-21 00:31:26 +02:00
// Clean up view before saving (so render is pure)
this.cropRect = null;
2025-12-21 18:45:40 +02:00
this.resetView();
2025-12-21 00:31:26 +02:00
this.render();
let finalCanvas = this.canvas;
if (rect) {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = rect.w;
tempCanvas.height = rect.h;
const tCtx = tempCanvas.getContext('2d');
tCtx.drawImage(this.canvas, rect.x, rect.y, rect.w, rect.h, 0, 0, rect.w, rect.h);
finalCanvas = tempCanvas;
this.cropRect = rect;
}
finalCanvas.toBlob((blob) => {
if (typeof saveProcessedImageToDB === 'function') {
saveProcessedImageToDB(blob, name);
this.close();
} else { alert("Error: saveProcessedImageToDB missing"); }
}, 'image/jpeg', 0.85);
}
};
< / script >
< / body >
< / html >