pcb-retrace/docs/studio.html

1600 lines
60 KiB
HTML
Raw Normal View History

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>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<!-- LIBRARIES -->
<script async src="https://docs.opencv.org/4.x/opencv.js" type="text/javascript"></script>
<script src="spyglass.js"></script>
<!-- CORE LOGIC -->
<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>
<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 */
.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 */
#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; }
@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 {
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 {
display: flex !important;
2025-12-21 00:31:26 +02:00
}
/* Ensure the grid fills remaining space */
#inspect-grid {
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 {
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 {
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; }
#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)">
<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>
<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>
<div id="tab-map" class="tab-btn" onclick="switchView('map')">🖼️ Images</div>
<div id="tab-nets" class="tab-btn" onclick="switchView('nets')">🕸️ Nets</div>
<div id="tab-inspect" class="tab-btn" onclick="switchView('inspect')">🔍 Inspect</div>
<div id="tab-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>
<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 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 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 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 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>
<!-- 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>
<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 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>
<!-- GENERIC INPUT MODAL -->
<div style="display:none; z-index:1150;" id="generic-input-modal" class="modal-overlay">
<div class="modal-content" style="height:auto; max-width:300px; background:#fff; border:1px solid #cbd5e1;">
<div class="modal-header" 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>
<button class="close-btn" onclick="document.getElementById('generic-input-modal').style.display='none'">×</button>
</div>
<div style="padding:1.5rem; display:flex; flex-direction:column; gap:1rem;">
<div>
<label id="gim-label" style="font-size:0.8rem; font-weight:700; color:#64748b; text-transform:uppercase; margin-bottom:0.2rem; display:block;">Value</label>
<input type="text" id="gim-input" style="width:100%; height:auto; padding:0.5rem; border:1px solid #cbd5e1; border-radius:0.3rem; font-size:1rem; box-sizing:border-box;">
<!-- 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>
</div>
<!-- (Footer remains the same) -->
<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 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 00:31:26 +02:00
this.canvas.addEventListener('mousedown', start);
this.canvas.addEventListener('touchstart', start, {passive: false});
2025-12-21 00:31:26 +02:00
window.addEventListener('mousemove', move);
window.addEventListener('touchmove', move, {passive: false});
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 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) {
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 00:31:26 +02:00
// Clean up view before saving (so render is pure)
this.cropRect = null;
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>