pcb-retrace/docs/studio.html

1573 lines
59 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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">
<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="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>
<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 {
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) */
.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; }
}
.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 */
}
/* When active, force flex display to enable the column layout */
#view-inspect.active {
display: flex !important;
}
/* Ensure the grid fills remaining space */
#inspect-grid {
flex: 1;
min-height: 0; /* Critical for nested flex scrolling/sizing */
}
/* Add to common.css or styles */
.net-chip {
background: #e2e8f0;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.85rem;
border: 1px solid #cbd5e1;
cursor: pointer;
}
.net-chip:hover {
background: #cbd5e1;
border-color: #94a3b8;
}
/* 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;
}
}
@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="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>
</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>
</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>
</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">
<!-- Single Floating Toolbar Container -->
<div id="inspect-toolbar" style="position:absolute; top:0.5rem; left:0.5rem; right:0.5rem; z-index:100; display:flex; gap:0.5rem; align-items:center; pointer-events:none;">
<!-- 1. Layers Dropdown -->
<div style="pointer-events:auto; padding:0 0.5rem; background:rgba(255,255,255,0.95); box-shadow:0 2px 10px rgba(0,0,0,0.3); border-radius:4px; height:2rem; display:flex; align-items:center;">
<details id="inspect-layers-details" style="position:relative;">
<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>
<!-- 3. New Net Button -->
<button class="primary sm-btn" style="pointer-events:auto; box-shadow:0 2px 5px rgba(0,0,0,0.5); height:2rem;" onclick="inspector.startNewNet()">+ New Net</button>
</div>
<!-- Full Screen Grid -->
<div id="inspect-grid">
<div style="display:flex; align-items:center; justify-content:center; color:#64748b; height:100%;">Select layers to inspect</div>
</div>
</div>
</main>
<footer class="app-footer">Copyright © 2025 Taras Greben — <a href="https://pcb.etaras.com">pcb.eTaras.com</a></footer>
<!-- 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 -->
<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">
<h3 style="margin:0" id="gim-title">Input</h3>
<button class="close-btn" onclick="document.getElementById('generic-input-modal').style.display='none'">×</button>
</div>
<div style="padding:1.5rem; display:flex; flex-direction:column; gap:1rem;">
<div>
<label id="gim-label" style="font-size:0.8rem; font-weight:700; color:#64748b; text-transform:uppercase; margin-bottom:0.2rem; display:block;">Value</label>
<input type="text" id="gim-input" style="width:100%; height:auto; padding:0.5rem; border:1px solid #cbd5e1; border-radius:0.3rem; font-size:1rem; box-sizing:border-box;">
</div>
<div style="display:flex; justify-content:space-between; gap:10px;" id="gim-footer">
<button id="gim-extra-btn" class="danger" style="display:none; padding:0.6rem 1rem; margin-right:auto;"></button>
<button id="gim-cancel-btn" class="secondary" style="padding:0.6rem 1rem;">Cancel</button>
<button id="gim-ok-btn" class="primary" style="padding:0.6rem 1rem;">OK</button>
</div>
</div>
</div>
</div>
<!-- MOBILE ACTION SHEET -->
<div style="display:none; align-items:flex-end;" id="mobile-menu-modal" class="modal-overlay" onclick="this.style.display='none'">
<div class="modal-content" style="width:100%; max-width:100%; border-radius:1rem 1rem 0 0; margin:0; animation: slideUp 0.2s ease-out;" onclick="event.stopPropagation()">
<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,
// 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();
this.canvas.addEventListener('mousedown', start);
this.canvas.addEventListener('touchstart', start, {passive: false});
window.addEventListener('mousemove', move);
window.addEventListener('touchmove', move, {passive: false});
window.addEventListener('mouseup', end);
window.addEventListener('touchend', end);
// 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();
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;
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;
// Clean up view before saving (so render is pure)
this.cropRect = null;
this.resetView();
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>