Report-Generator / templates /cropv2.html
root
Fix manual classification and crop navigation UX
f1ed485
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, maximum-scale=1, viewport-fit=cover">
<title>Crop {% if two_page_mode %}Pages {{ left_page_index + 1 }}-{{ right_page_index + 1 }}{% else %}Page {{ image_index + 1 }}{% endif %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
/* --- CORE VARIABLES --- */
:root {
--header-height: 60px;
--slider-height: 90px;
--bg-dark: #121212;
--surface-color: #1e1e1e;
--lens-size: 140px;
--accent-primary: #0d6efd;
--accent-info: #0dcaf0;
--transition-fast: 0.15s ease;
--transition-normal: 0.25s ease;
}
html, body {
height: 100%;
height: 100dvh;
width: 100%;
margin: 0;
padding: 0;
overflow: hidden;
background-color: #181a1c;
font-family: system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
}
/* --- ENHANCED PROGRESS BAR WITH BYTES --- */
#progress-container {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 10000;
pointer-events: none;
}
#progress-bar {
height: 3px;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-info));
transform: scaleX(0);
transform-origin: left;
transition: transform 0.1s linear;
box-shadow: 0 0 10px rgba(13, 110, 253, 0.5);
}
#progress-bar.active {
/* Animation removed for cleaner UI */
}
#progress-info {
position: absolute;
top: 8px;
right: 16px;
background: rgba(33, 37, 41, 0.95);
color: #fff;
padding: 6px 12px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
opacity: 0;
transform: translateY(-10px);
transition: all var(--transition-normal);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.1);
pointer-events: auto;
}
#progress-info.show {
opacity: 1;
transform: translateY(0);
}
#progress-info .progress-text {
color: var(--accent-info);
}
/* --- 1. HEADER --- */
.app-header {
height: var(--header-height);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
padding-top: env(safe-area-inset-top);
padding-left: calc(16px + env(safe-area-inset-left));
padding-right: calc(16px + env(safe-area-inset-right));
background: linear-gradient(180deg, #343a40, #2c3034);
border-bottom: 1px solid #495057;
z-index: 50;
}
.header-title { font-size: 1rem; font-weight: 600; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #fff; }
.header-actions { display: flex; gap: 12px; }
.header-actions .btn {
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50px;
transition: all var(--transition-fast);
}
.header-actions .btn:hover {
transform: translateY(-2px);
}
.header-actions .btn:active {
transform: translateY(0) scale(0.95);
}
/* --- 2. WORKSPACE --- */
.content-wrapper { flex: 1; position: relative; background-color: #181a1c; overflow: hidden; display: flex; flex-direction: column; width: 100%; }
.image-pane { flex-grow: 1; position: relative; display: flex; align-items: center; justify-content: center; overflow: hidden; width: 100%; height: 100%; }
/* Two-Page Layout */
.image-pane.two-page-mode {
gap: 8px;
padding: 4px;
flex-direction: row;
flex-wrap: nowrap;
}
.two-page-mode #crop-area,
.two-page-mode .crop-area-right {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.page-label {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.75);
color: #fff;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
z-index: 15;
pointer-events: none;
}
.crop-area-right {
position: relative;
line-height: 0;
box-shadow: 0 0 30px rgba(0,0,0,0.5);
user-select: none;
-webkit-user-select: none;
transition: opacity var(--transition-normal);
}
.crop-area-right.loading { opacity: 0.5; }
.crop-area-right img {
display: block;
pointer-events: none;
opacity: 0;
transition: opacity 0.4s ease-out;
}
.crop-area-right img.loaded { opacity: 1; }
.crop-area-right canvas {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 10;
cursor: crosshair;
touch-action: none;
}
#crop-area {
position: relative;
line-height: 0;
box-shadow: 0 0 30px rgba(0,0,0,0.5);
user-select: none;
-webkit-user-select: none;
transition: opacity var(--transition-normal);
}
#crop-area.loading {
opacity: 0.5;
}
#main-image {
display: block;
pointer-events: none;
opacity: 0;
transition: opacity 0.4s ease-out;
}
#main-image.loaded {
opacity: 1;
}
#draw-canvas {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 10;
cursor: crosshair;
touch-action: none;
}
/* --- MAGNIFIER LENS --- */
#magnifier, #magnifier-right {
position: absolute;
width: var(--lens-size);
height: var(--lens-size);
border: 3px solid #fff;
border-radius: 50%;
box-shadow: 0 4px 15px rgba(0,0,0,0.6), inset 0 0 20px rgba(0,0,0,0.2);
pointer-events: none;
display: none;
z-index: 1000;
background-repeat: no-repeat;
background-color: #000;
overflow: hidden;
transition: opacity var(--transition-fast);
}
#magnifier::after, #magnifier-right::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 12px;
height: 12px;
border-left: 2px solid rgba(255,50,50,0.8);
border-top: 2px solid rgba(255,50,50,0.8);
transform: translate(-50%, -50%);
}
/* --- 3. THUMBNAILS --- */
.thumbnail-bar {
height: var(--slider-height);
flex-shrink: 0;
background: linear-gradient(180deg, #212529, #1a1d20);
border-top: 1px solid #495057;
display: flex;
align-items: center;
padding: 0 10px;
padding-bottom: env(safe-area-inset-bottom);
gap: 10px;
overflow-x: auto;
z-index: 40;
}
.thumb-item {
height: 60px;
min-width: 45px;
border-radius: 8px;
overflow: hidden;
position: relative;
border: 2px solid transparent;
opacity: 0.6;
background: #000;
flex-shrink: 0;
cursor: pointer;
transition: all var(--transition-normal);
}
.thumb-item:hover {
opacity: 0.85;
transform: scale(1.02);
}
.thumb-item.active {
border-color: var(--accent-primary);
opacity: 1;
transform: scale(1.08);
box-shadow: 0 0 12px rgba(13, 110, 253, 0.4);
}
.thumb-item img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.4s ease-out;
}
.thumb-item img.loaded { opacity: 1; }
.thumb-item .thumb-loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
border: 2px solid #495057;
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: translate(-50%, -50%) rotate(360deg); }
}
.thumb-number {
position: absolute;
bottom: 0;
right: 0;
background: rgba(0,0,0,0.75);
color: #fff;
font-size: 10px;
padding: 2px 5px;
border-radius: 4px 0 0 0;
}
/* --- FLOATING UI --- */
#box-toolbar {
position: absolute;
background: rgba(33, 37, 41, 0.95);
border: 1px solid #6c757d;
border-radius: 50px;
padding: 8px 16px;
display: none;
gap: 16px;
backdrop-filter: blur(8px);
z-index: 100;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
opacity: 0;
transform: translateY(10px);
transition: all var(--transition-normal);
}
#box-toolbar.show {
opacity: 1;
transform: translateY(0);
}
#box-toolbar button {
background: transparent;
border: none;
color: #e9ecef;
width: 32px;
height: 32px;
font-size: 1.4rem;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
border-radius: 50%;
}
#box-toolbar button:hover {
background: rgba(255,255,255,0.1);
transform: scale(1.1);
}
#box-toolbar button:active {
transform: scale(0.9);
color: #fff;
}
#box-toolbar button.delete-btn { color: #dc3545; }
#box-toolbar button.delete-btn:hover { background: rgba(220, 53, 69, 0.2); }
/* Secondary toolbar for right page */
.box-toolbar-secondary {
position: absolute;
background: rgba(33, 37, 41, 0.95);
border: 1px solid #6c757d;
border-radius: 50px;
padding: 8px 16px;
display: none;
gap: 16px;
backdrop-filter: blur(8px);
z-index: 100;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
opacity: 0;
transform: translateY(10px);
transition: all var(--transition-normal);
}
.box-toolbar-secondary.show {
opacity: 1;
transform: translateY(0);
}
.box-toolbar-secondary button {
background: transparent;
border: none;
color: #e9ecef;
width: 32px;
height: 32px;
font-size: 1.4rem;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
border-radius: 50%;
}
.box-toolbar-secondary button:hover {
background: rgba(255,255,255,0.1);
transform: scale(1.1);
}
.box-toolbar-secondary button:active {
transform: scale(0.9);
color: #fff;
}
.box-toolbar-secondary button.delete-btn { color: #dc3545; }
.box-toolbar-secondary button.delete-btn:hover { background: rgba(220, 53, 69, 0.2); }
/* --- FAB CONTAINER - Improved Spacing --- */
.fab-container {
position: absolute;
bottom: 20px;
right: 20px;
z-index: 45;
display: flex;
flex-direction: column;
gap: 16px;
}
.fab-btn {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #2c3034, #212529);
border: 1px solid #495057;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
transition: all var(--transition-normal);
cursor: pointer;
}
.fab-btn:hover {
transform: scale(1.1) translateY(-2px);
background: linear-gradient(135deg, #3d444b, #2c3034);
box-shadow: 0 6px 24px rgba(0,0,0,0.5);
}
.fab-btn:active {
transform: scale(0.95);
}
.fab-btn.active {
background: linear-gradient(135deg, var(--accent-primary), #0b5ed7);
border-color: var(--accent-primary);
}
/* --- PANELS - Better Positioning --- */
.filters-panel {
position: absolute;
right: 0;
background: rgba(33, 37, 41, 0.95);
border-radius: 12px;
padding: 16px;
width: 220px;
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
border: 1px solid #495057;
backdrop-filter: blur(8px);
opacity: 0;
transform: translateX(10px);
pointer-events: none;
transition: all var(--transition-normal);
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.filters-panel.show {
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
/* Data Panel - Position from bottom of FAB container */
#dataPanel {
bottom: 80px;
right: 76px;
}
/* Brightness Panel - Position from bottom of FAB container */
#filtersPanel {
bottom: 80px;
right: 76px;
}
.form-range { height: 4px; }
#loader-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.8);
display: none;
align-items: center;
justify-content: center;
z-index: 9999;
backdrop-filter: blur(4px);
}
.toast-container {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
z-index: 2000;
width: auto;
pointer-events: none;
}
.toast {
background: rgba(33, 37, 41, 0.95);
color: white;
border: 1px solid #495057;
pointer-events: auto;
backdrop-filter: blur(8px);
border-radius: 12px;
animation: toast-slide-in 0.3s ease-out;
}
@keyframes toast-slide-in {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
</head>
<body>
{% include '_navbar.html' %}
<!-- Enhanced Progress Bar with Bytes Info -->
<div id="progress-container">
<div id="progress-bar"></div>
<div id="progress-info">
<span class="progress-text">0%</span> · <span id="progress-bytes">0 KB / 0 KB</span>
</div>
</div>
<header class="app-header">
<h1 class="header-title" id="app-header-title"><i class="bi bi-bounding-box me-2"></i>{% if two_page_mode %}Pages {{ left_page_index + 1 }}-{{ right_page_index + 1 if right_image_info else left_page_index + 1 }} / {{ total_pages }}{% else %}Page {{ image_index + 1 }} / {{ total_pages }}{% endif %}</h1>
<div class="header-actions">
<button id="backBtn" class="btn btn-secondary" aria-label="Back"><i class="bi bi-arrow-left"></i></button>
<button id="clearBtn" class="btn btn-outline-info" aria-label="Clear All"><i class="bi bi-eraser"></i></button>
<button id="processBtn" class="btn btn-success ps-3 pe-3">Next <i class="bi bi-chevron-right ms-1"></i></button>
</div>
</header>
<div class="content-wrapper">
<div class="image-pane{% if two_page_mode %} two-page-mode{% endif %}" id="imagePane">
<div id="crop-area">
{% if two_page_mode %}<div class="page-label" id="page-label-left">Page {{ left_page_index + 1 }}</div>{% endif %}
<img id="main-image" src="/image/upload/{{ image_info.filename }}" alt="Page" crossorigin="anonymous">
<div id="magnifier"></div>
<canvas id="draw-canvas"></canvas>
<div id="box-toolbar">
<button id="stitch-btn" title="Stitch"><i class="bi bi-scissors"></i></button>
<button id="move-up-btn" title="Move Up"><i class="bi bi-arrow-up-circle"></i></button>
<button id="move-down-btn" title="Move Down"><i class="bi bi-arrow-down-circle"></i></button>
<button id="delete-btn" title="Delete Box" class="delete-btn"><i class="bi bi-trash"></i></button>
</div>
</div>
{% if two_page_mode %}
<div class="crop-area-right" id="crop-area-right" style="{% if not right_image_info %}display: none;{% endif %}">
<div class="page-label" id="page-label-right">Page {{ right_page_index + 1 }}</div>
<img id="right-image" src="{% if right_image_info %}/image/upload/{{ right_image_info.filename }}{% endif %}" alt="Right Page" crossorigin="anonymous">
<div id="magnifier-right"></div>
<canvas id="draw-canvas-right"></canvas>
<div id="box-toolbar-right" class="box-toolbar-secondary">
<button id="stitch-btn-right" title="Stitch"><i class="bi bi-scissors"></i></button>
<button id="move-up-btn-right" title="Move Up"><i class="bi bi-arrow-up-circle"></i></button>
<button id="move-down-btn-right" title="Move Down"><i class="bi bi-arrow-down-circle"></i></button>
<button id="delete-btn-right" title="Delete Box" class="delete-btn"><i class="bi bi-trash"></i></button>
</div>
</div>
{% endif %}
<!-- Floating Actions -->
<div class="fab-container">
<!-- Data Panel - Higher up -->
<div class="filters-panel" id="dataPanel">
<div id="no-selection-msg" class="text-center text-muted small py-3">Select a box to edit</div>
<div id="data-form" style="display: none;">
<div class="mb-2">
<label class="small text-secondary">Q. No</label>
<input type="text" class="form-control form-control-sm bg-dark text-white border-secondary" id="box-q-num">
</div>
<div class="mb-2">
<label class="small text-secondary">Status</label>
<select class="form-select form-select-sm bg-dark text-white border-secondary" id="box-status">
<option value="unattempted">Unattempted</option>
<option value="correct">Correct</option>
<option value="wrong">Wrong</option>
</select>
</div>
<div class="row g-2 mb-0">
<div class="col-6">
<label class="small text-secondary">Marked</label>
<input type="text" class="form-control form-control-sm bg-dark text-white border-secondary" id="box-marked">
</div>
<div class="col-6">
<label class="small text-secondary">Actual</label>
<input type="text" class="form-control form-control-sm bg-dark text-white border-secondary" id="box-actual">
</div>
</div>
</div>
</div>
<button class="fab-btn" id="dataToggle" title="Data Entry"><i class="bi bi-pencil-square"></i></button>
<!-- Brightness Panel - Lower -->
<div class="filters-panel" id="filtersPanel">
<div class="mb-3">
<label class="small text-secondary d-flex justify-content-between">
Brightness <span id="val-b" class="text-white">0</span>
</label>
<input type="range" class="form-range" id="brightness" min="-100" max="100" value="0" step="5">
</div>
<div class="mb-3">
<label class="small text-secondary d-flex justify-content-between">
Contrast <span id="val-c" class="text-white">1.0</span>
</label>
<input type="range" class="form-range" id="contrast" min="0.5" max="2.5" step="0.05" value="1.0">
</div>
<div class="mb-0">
<label class="small text-secondary d-flex justify-content-between">
Gamma <span id="val-g" class="text-white">1.0</span>
</label>
<input type="range" class="form-range" id="gamma" min="0.2" max="2.2" step="0.1" value="1.0">
</div>
</div>
<button class="fab-btn" id="filterToggle" title="Brightness & Filters"><i class="bi bi-sliders"></i></button>
</div>
</div>
<div class="thumbnail-bar">
{% if two_page_mode %}
{% for i in range((all_pages|length + 1) // 2) %}
{% set left_idx = i * 2 %}
{% set right_idx = left_idx + 1 %}
<div class="thumb-item {% if i == image_index %}active{% endif %}" data-page-index="{{ i }}">
<div class="thumb-loader"></div>
<img data-src="/image/upload/{{ all_pages[left_idx].filename }}"
alt="Pages {{ left_idx + 1 }}-{{ right_idx + 1 }}"
data-session="{{ session_id }}"
class="thumb-img">
<div class="thumb-number">{{ left_idx + 1 }}-{{ right_idx + 1 if right_idx < all_pages|length else left_idx + 1 }}</div>
</div>
{% endfor %}
{% else %}
{% for page in all_pages %}
<div class="thumb-item {% if page.image_index == image_index %}active{% endif %}" data-page-index="{{ page.image_index }}">
<div class="thumb-loader"></div>
<img data-src="/image/upload/{{ page.filename }}"
alt="Page {{ page.image_index + 1 }}"
data-session="{{ session_id }}"
class="thumb-img">
<div class="thumb-number">{{ page.image_index + 1 }}</div>
</div>
{% endfor %}
{% endif %}
</div>
</div>
<div id="loader-overlay"><div class="spinner-border text-light"></div></div>
<div class="toast-container" id="toastContainer"></div>
<script>
// CONFIG
const CONFIG = {
sessionId: '{{ session_id }}',
userId: '{{ user_id }}',
imageIndex: parseInt('{{ image_index }}'),
totalPages: parseInt('{{ total_pages }}'),
enableMagnifier: {{ 'true' if current_user.magnifier_enabled else 'false' }},
twoPageMode: {{ 'true' if two_page_mode else 'false' }},
leftPageIndex: parseInt('{{ left_page_index|default(image_index) }}'),
rightPageIndex: parseInt('{{ right_page_index|default(-1) }}'),
hasRightPage: {{ 'true' if two_page_mode and right_image_info else 'false' }},
allPages: {{ all_pages|tojson }}
};
let storageKey = CONFIG.userId ? `cropState_${CONFIG.userId}_${CONFIG.sessionId}_${CONFIG.leftPageIndex}` : `cropState_${CONFIG.sessionId}_${CONFIG.leftPageIndex}`;
let storageKeyRight = CONFIG.hasRightPage ? (CONFIG.userId ? `cropState_${CONFIG.userId}_${CONFIG.sessionId}_${CONFIG.rightPageIndex}` : `cropState_${CONFIG.sessionId}_${CONFIG.rightPageIndex}`) : null;
// --- ENHANCED PROGRESS BAR WITH BYTES ---
const ProgressBar = {
el: document.getElementById('progress-bar'),
infoEl: document.getElementById('progress-info'),
textEl: document.querySelector('#progress-info .progress-text'),
bytesEl: document.getElementById('progress-bytes'),
activeRequests: new Map(), // url -> {loaded, total}
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
},
show(progress = 0) {
if (progress > 0) this.el.style.transform = `scaleX(${progress})`;
this.el.classList.add('active');
this.infoEl.classList.add('show');
},
update(url, loaded, total) {
this.activeRequests.set(url, { loaded, total });
this.calculateTotal();
},
calculateTotal() {
let totalLoaded = 0;
let totalSize = 0;
let count = 0;
this.activeRequests.forEach(req => {
totalLoaded += req.loaded;
totalSize += req.total;
count++;
});
if (totalSize > 0) {
const p = totalLoaded / totalSize;
this.el.style.transform = `scaleX(${p})`;
this.textEl.textContent = Math.round(p * 100) + '%';
this.bytesEl.textContent = `${this.formatBytes(totalLoaded)} / ${this.formatBytes(totalSize)}`;
}
},
removeRequest(url) {
this.activeRequests.delete(url);
if (this.activeRequests.size === 0) {
this.hide();
} else {
this.calculateTotal();
}
},
hide() {
this.el.classList.remove('active');
this.infoEl.classList.remove('show');
setTimeout(() => {
if (this.activeRequests.size === 0) {
this.el.style.transform = 'scaleX(0)';
this.textEl.textContent = '0%';
this.bytesEl.textContent = '0 KB / 0 KB';
}
}, 300);
}
};
// --- FETCH QUEUE FOR PARALLEL LOADING ---
const FetchQueue = {
maxParallel: 8,
running: 0,
queue: [],
async add(task) {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this.process();
});
},
async process() {
if (this.running >= this.maxParallel || this.queue.length === 0) return;
this.running++;
const { task, resolve, reject } = this.queue.shift();
try {
const result = await task();
resolve(result);
} catch (err) {
reject(err);
} finally {
this.running--;
this.process();
}
}
};
// --- INDEXED DB CACHE WITH LAZY LOADING ---
const ThumbCache = {
DB_NAME: 'PDF_Crop_Thumbs',
STORE_NAME: 'images',
EXPIRY_MS: 2 * 24 * 60 * 60 * 1000,
observer: null,
normalizeUrl(url) {
if (!url) return '';
// Ensure we only use the pathname for consistent cache keys
try {
const link = document.createElement('a');
link.href = url;
return link.pathname;
} catch (e) {
return url;
}
},
async open() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(this.DB_NAME, 1);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(this.STORE_NAME)) {
db.createObjectStore(this.STORE_NAME, { keyPath: 'url' });
}
};
req.onsuccess = (e) => resolve(e.target.result);
req.onerror = (e) => reject(e);
});
},
async getBlob(url) {
const normUrl = this.normalizeUrl(url);
try {
const db = await this.open();
return new Promise((resolve) => {
const tx = db.transaction(this.STORE_NAME, 'readonly');
const req = tx.objectStore(this.STORE_NAME).get(normUrl);
req.onsuccess = (e) => resolve(e.target.result ? e.target.result.blob : null);
req.onerror = () => resolve(null);
});
} catch (e) {
return null;
}
},
async cleanup() {
const db = await this.open();
return new Promise((resolve) => {
const tx = db.transaction(this.STORE_NAME, 'readwrite');
const store = tx.objectStore(this.STORE_NAME);
const now = Date.now();
const req = store.openCursor();
req.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
if ((now - cursor.value.timestamp) > this.EXPIRY_MS) {
cursor.delete();
}
cursor.continue();
} else {
resolve();
}
};
req.onerror = () => resolve();
});
},
async loadImage(url, imgElement, loaderElement, showProgress = false) {
const normUrl = this.normalizeUrl(url);
try {
const blob = await this.getBlob(normUrl);
if (blob) {
// Cached - load instantly
imgElement.src = URL.createObjectURL(blob);
imgElement.classList.add('loaded');
if (loaderElement) loaderElement.remove();
} else {
// Not cached - queue fetch
try {
const newBlob = await FetchQueue.add(() => this.fetchWithProgress(normUrl, showProgress));
const db = await this.open();
const txWrite = db.transaction(this.STORE_NAME, 'readwrite');
txWrite.objectStore(this.STORE_NAME).put({
url: normUrl,
blob: newBlob,
timestamp: Date.now(),
sessionId: CONFIG.sessionId
});
imgElement.src = URL.createObjectURL(newBlob);
imgElement.classList.add('loaded');
if (loaderElement) loaderElement.remove();
} catch (err) {
console.error('Fetch failed for', normUrl, err);
imgElement.src = url; // Fallback to direct URL if fetch fails
imgElement.classList.add('loaded');
if (loaderElement) loaderElement.remove();
}
}
} catch (err) {
imgElement.src = url;
imgElement.classList.add('loaded');
if (loaderElement) loaderElement.remove();
}
},
fetchWithProgress(url, showProgress = false) {
const normUrl = this.normalizeUrl(url);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', normUrl, true);
xhr.responseType = 'blob';
if (showProgress) {
ProgressBar.show();
ProgressBar.update(normUrl, 0, 0); // Init
}
xhr.onprogress = (e) => {
if (e.lengthComputable && showProgress) {
ProgressBar.update(normUrl, e.loaded, e.total);
}
};
xhr.onload = () => {
if (showProgress) {
ProgressBar.update(normUrl, xhr.response.size, xhr.response.size);
setTimeout(() => ProgressBar.removeRequest(normUrl), 200);
}
if (xhr.status === 200) {
resolve(xhr.response);
} else {
reject(new Error(`HTTP ${xhr.status}`));
}
};
xhr.onerror = () => {
if (showProgress) ProgressBar.removeRequest(normUrl);
reject(new Error('Network error'));
};
xhr.send();
});
},
initLazyLoading() {
this.cleanup();
const options = {
root: document.querySelector('.thumbnail-bar'),
rootMargin: '50px',
threshold: 0.01
};
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target.querySelector('.thumb-img');
const loader = entry.target.querySelector('.thumb-loader');
const url = img.getAttribute('data-src');
if (url && !img.classList.contains('loaded')) {
this.loadImage(url, img, loader);
}
this.observer.unobserve(entry.target);
}
});
}, options);
// Load active thumbnail immediately
const activeThumb = document.querySelector('.thumb-item.active');
if (activeThumb) {
const img = activeThumb.querySelector('.thumb-img');
const loader = activeThumb.querySelector('.thumb-loader');
const url = img.getAttribute('data-src');
if (url) this.loadImage(url, img, loader);
}
// Observe all other thumbnails for lazy loading
document.querySelectorAll('.thumb-item:not(.active)').forEach(thumb => {
this.observer.observe(thumb);
});
// BACKGROUND PRE-CACHE: Also queue all images for caching in the background
// This ensures everything is local for faster run times even if not scrolled into view
CONFIG.allPages.forEach(page => {
const url = `/image/upload/${page.filename}`;
// We don't need img/loader elements for pre-caching, loadImage will handle it if we adapt it or just call getBlob/fetch
this.preCache(url);
});
},
async preCache(url) {
const normUrl = this.normalizeUrl(url);
const blob = await this.getBlob(normUrl);
if (!blob) {
// Not in cache, fetch and store
try {
const newBlob = await FetchQueue.add(() => this.fetchWithProgress(normUrl, false));
const db = await this.open();
const tx = db.transaction(this.STORE_NAME, 'readwrite');
tx.objectStore(this.STORE_NAME).put({
url: normUrl,
blob: newBlob,
timestamp: Date.now(),
sessionId: CONFIG.sessionId
});
} catch (e) {}
}
}
};
// --- APP LOGIC ---
const els = {
image: document.getElementById('main-image'),
imagePane: document.getElementById('imagePane'),
cropArea: document.getElementById('crop-area'),
canvas: document.getElementById('draw-canvas'),
ctx: document.getElementById('draw-canvas').getContext('2d'),
toolbar: document.getElementById('box-toolbar'),
magnifier: document.getElementById('magnifier'),
// Right page elements (two-page mode)
rightImage: CONFIG.twoPageMode ? document.getElementById('right-image') : null,
rightCropArea: CONFIG.twoPageMode ? document.getElementById('crop-area-right') : null,
rightCanvas: CONFIG.twoPageMode ? document.getElementById('draw-canvas-right') : null,
rightCtx: CONFIG.twoPageMode ? document.getElementById('draw-canvas-right')?.getContext('2d') : null,
rightToolbar: CONFIG.twoPageMode ? document.getElementById('box-toolbar-right') : null,
rightMagnifier: CONFIG.twoPageMode ? document.getElementById('magnifier-right') : null
};
let boxes = [];
let boxesRight = []; // For right page in two-page mode
let selectedBoxIndex = -1;
let selectedBoxIndexRight = -1;
let activePane = 'left'; // Which pane is currently active
let isDrawing = false;
let startX, startY;
let dragTarget = null;
let startPositions = {};
let stitchBuffer = JSON.parse(localStorage.getItem('gemini_stitch_buffer') || 'null');
// ZOOM STATE
const LENS_SIZE_PX = 140;
const ZOOM_LEVEL = 2.5;
let isMagnifying = false;
let magnifierPos = { x: 0, y: 0 };
function init() {
// Initialize history state for SPA navigation
window.history.replaceState({ imageIndex: CONFIG.imageIndex }, '', window.location.pathname);
ThumbCache.initLazyLoading();
// Load main image with progress tracking
const leftUrl = `/image/upload/${CONFIG.allPages[CONFIG.leftPageIndex].filename}`;
loadMainImageWithProgress(leftUrl);
// Load right image if in two-page mode
if (CONFIG.twoPageMode && CONFIG.hasRightPage) {
const rightUrl = `/image/upload/${CONFIG.allPages[CONFIG.rightPageIndex].filename}`;
loadRightImageWithProgress(rightUrl);
}
const ro = new ResizeObserver(() => requestAnimationFrame(fitImage));
ro.observe(els.imagePane);
loadSettings();
loadBoxes();
if (CONFIG.hasRightPage) loadBoxesRight();
setupListeners();
updateStitchButton();
const active = document.querySelector('.thumb-item.active');
if (active) active.scrollIntoView({ inline: 'center' });
}
async function loadMainImageWithProgress(url) {
const normUrl = ThumbCache.normalizeUrl(url);
els.cropArea.classList.add('loading');
try {
// Check cache first
let blob = await ThumbCache.getBlob(normUrl);
if (!blob) {
blob = await FetchQueue.add(() => ThumbCache.fetchWithProgress(normUrl, true));
// Store in cache
const db = await ThumbCache.open();
const txWrite = db.transaction(ThumbCache.STORE_NAME, 'readwrite');
txWrite.objectStore(ThumbCache.STORE_NAME).put({
url: normUrl,
blob: blob,
timestamp: Date.now(),
sessionId: CONFIG.sessionId
});
}
els.image.src = URL.createObjectURL(blob);
els.image.onload = () => {
els.image.classList.add('loaded');
els.cropArea.classList.remove('loading');
fitImage();
};
} catch (err) {
// Fallback to direct load
els.image.src = url;
els.image.onload = () => {
els.image.classList.add('loaded');
els.cropArea.classList.remove('loading');
fitImage();
};
}
}
async function loadRightImageWithProgress(url) {
if (!CONFIG.twoPageMode || !els.rightImage) return;
const normUrl = ThumbCache.normalizeUrl(url);
els.rightCropArea.classList.add('loading');
try {
// Check cache first
let blob = await ThumbCache.getBlob(normUrl);
if (!blob) {
blob = await FetchQueue.add(() => ThumbCache.fetchWithProgress(normUrl, true));
// Store in cache
const db = await ThumbCache.open();
const txWrite = db.transaction(ThumbCache.STORE_NAME, 'readwrite');
txWrite.objectStore(ThumbCache.STORE_NAME).put({
url: normUrl,
blob: blob,
timestamp: Date.now(),
sessionId: CONFIG.sessionId
});
}
els.rightImage.src = URL.createObjectURL(blob);
els.rightImage.onload = () => {
els.rightImage.classList.add('loaded');
els.rightCropArea.classList.remove('loading');
fitImage();
};
} catch (err) {
els.rightImage.src = url;
els.rightImage.onload = () => {
els.rightImage.classList.add('loaded');
els.rightCropArea.classList.remove('loading');
fitImage();
};
}
}
function fitImage() {
if (!els.image.naturalWidth) return;
const rect = els.imagePane.getBoundingClientRect();
const padding = 4;
const gap = 8;
if (CONFIG.twoPageMode) {
// Two-page mode: always side by side, maximize space
const halfWidth = (rect.width - gap) / 2 - padding;
const fullHeight = rect.height - padding * 2;
// Fit left image - maximize space
const scaleLeft = Math.min(halfWidth / els.image.naturalWidth, fullHeight / els.image.naturalHeight);
const finalWLeft = Math.floor(els.image.naturalWidth * scaleLeft);
const finalHLeft = Math.floor(els.image.naturalHeight * scaleLeft);
els.cropArea.style.width = `${finalWLeft}px`;
els.cropArea.style.height = `${finalHLeft}px`;
els.image.style.width = `${finalWLeft}px`;
els.image.style.height = `${finalHLeft}px`;
els.canvas.width = finalWLeft;
els.canvas.height = finalHLeft;
if (CONFIG.enableMagnifier) {
els.magnifier.style.backgroundImage = `url('${els.image.src}')`;
}
// Fit right image if present - maximize space
if (CONFIG.hasRightPage && els.rightImage && els.rightImage.naturalWidth) {
const scaleRight = Math.min(halfWidth / els.rightImage.naturalWidth, fullHeight / els.rightImage.naturalHeight);
const finalWRight = Math.floor(els.rightImage.naturalWidth * scaleRight);
const finalHRight = Math.floor(els.rightImage.naturalHeight * scaleRight);
els.rightCropArea.style.width = `${finalWRight}px`;
els.rightCropArea.style.height = `${finalHRight}px`;
els.rightImage.style.width = `${finalWRight}px`;
els.rightImage.style.height = `${finalHRight}px`;
els.rightCanvas.width = finalWRight;
els.rightCanvas.height = finalHRight;
if (CONFIG.enableMagnifier && els.rightMagnifier) {
els.rightMagnifier.style.backgroundImage = `url('${els.rightImage.src}')`;
}
}
} else {
// Single-page mode - use full available space
const availableW = rect.width - padding * 2;
const availableH = rect.height - padding * 2;
const scale = Math.min(availableW / els.image.naturalWidth, availableH / els.image.naturalHeight);
const finalW = Math.floor(els.image.naturalWidth * scale);
const finalH = Math.floor(els.image.naturalHeight * scale);
els.cropArea.style.width = `${finalW}px`;
els.cropArea.style.height = `${finalH}px`;
els.image.style.width = `${finalW}px`;
els.image.style.height = `${finalH}px`;
els.canvas.width = finalW;
els.canvas.height = finalH;
if (CONFIG.enableMagnifier) {
els.magnifier.style.backgroundImage = `url('${els.image.src}')`;
}
}
if (selectedBoxIndex !== -1) updateToolbar();
if (selectedBoxIndexRight !== -1 && CONFIG.hasRightPage) updateToolbar();
drawBoxes();
if (CONFIG.hasRightPage) drawBoxesRight();
}
// --- DRAWING ENGINE ---
function drawBoxes() {
els.ctx.clearRect(0, 0, els.canvas.width, els.canvas.height);
boxes.forEach((box, index) => {
const isSelected = index === selectedBoxIndex;
const isStitched = box.remote_stitch_source != null;
const p = (pt) => ({ x: pt.x * els.canvas.width, y: pt.y * els.canvas.height });
els.ctx.lineWidth = isSelected ? 3 : 2;
els.ctx.strokeStyle = isSelected ? '#ff4d4d' : (isStitched ? '#0dcaf0' : '#ffc107');
els.ctx.fillStyle = isSelected ? 'rgba(255, 77, 77, 0.15)' : (isStitched ? 'rgba(13, 202, 240, 0.2)' : 'rgba(255, 193, 7, 0.1)');
els.ctx.beginPath();
els.ctx.moveTo(p(box.tl).x, p(box.tl).y);
els.ctx.lineTo(p(box.tr).x, p(box.tr).y);
els.ctx.lineTo(p(box.br).x, p(box.br).y);
els.ctx.lineTo(p(box.bl).x, p(box.bl).y);
els.ctx.closePath();
els.ctx.stroke();
els.ctx.fill();
if (isSelected) {
els.ctx.fillStyle = 'white';
['tl', 'tr', 'bl', 'br'].forEach(k => {
els.ctx.beginPath();
els.ctx.arc(p(box[k]).x, p(box[k]).y, 8, 0, Math.PI * 2);
els.ctx.fill();
els.ctx.stroke();
});
}
const cx = (p(box.tl).x + p(box.br).x) / 2;
const cy = (p(box.tl).y + p(box.br).y) / 2;
els.ctx.font = "bold 24px system-ui";
els.ctx.fillStyle = "white";
els.ctx.shadowColor = "rgba(0,0,0,0.8)";
els.ctx.shadowBlur = 6;
els.ctx.fillText(index + 1, cx - 6, cy + 8);
if (isStitched) {
els.ctx.font = "20px system-ui";
els.ctx.fillText("🔗", p(box.tr).x - 28, p(box.tr).y + 24);
}
els.ctx.shadowBlur = 0;
});
if (isMagnifying && CONFIG.enableMagnifier) {
const px = magnifierPos.x * els.canvas.width;
const py = magnifierPos.y * els.canvas.height;
const sourceRadius = (LENS_SIZE_PX / ZOOM_LEVEL) / 2;
els.ctx.beginPath();
els.ctx.arc(px, py, sourceRadius, 0, Math.PI * 2);
els.ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
els.ctx.lineWidth = 1.5;
els.ctx.shadowColor = 'rgba(0,0,0,1)';
els.ctx.shadowBlur = 4;
els.ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
els.ctx.stroke();
els.ctx.fill();
els.ctx.shadowBlur = 0;
}
}
// --- DRAWING ENGINE FOR RIGHT PAGE ---
function drawBoxesRight() {
if (!CONFIG.hasRightPage || !els.rightCtx) return;
els.rightCtx.clearRect(0, 0, els.rightCanvas.width, els.rightCanvas.height);
boxesRight.forEach((box, index) => {
const isSelected = index === selectedBoxIndexRight && activePane === 'right';
const isStitched = box.remote_stitch_source != null;
const p = (pt) => ({ x: pt.x * els.rightCanvas.width, y: pt.y * els.rightCanvas.height });
els.rightCtx.lineWidth = isSelected ? 3 : 2;
els.rightCtx.strokeStyle = isSelected ? '#ff4d4d' : (isStitched ? '#0dcaf0' : '#ffc107');
els.rightCtx.fillStyle = isSelected ? 'rgba(255, 77, 77, 0.15)' : (isStitched ? 'rgba(13, 202, 240, 0.2)' : 'rgba(255, 193, 7, 0.1)');
els.rightCtx.beginPath();
els.rightCtx.moveTo(p(box.tl).x, p(box.tl).y);
els.rightCtx.lineTo(p(box.tr).x, p(box.tr).y);
els.rightCtx.lineTo(p(box.br).x, p(box.br).y);
els.rightCtx.lineTo(p(box.bl).x, p(box.bl).y);
els.rightCtx.closePath();
els.rightCtx.stroke();
els.rightCtx.fill();
if (isSelected) {
els.rightCtx.fillStyle = 'white';
['tl', 'tr', 'bl', 'br'].forEach(k => {
els.rightCtx.beginPath();
els.rightCtx.arc(p(box[k]).x, p(box[k]).y, 8, 0, Math.PI * 2);
els.rightCtx.fill();
els.rightCtx.stroke();
});
}
const cx = (p(box.tl).x + p(box.br).x) / 2;
const cy = (p(box.tl).y + p(box.br).y) / 2;
els.rightCtx.font = "bold 24px system-ui";
els.rightCtx.fillStyle = "white";
els.rightCtx.shadowColor = "rgba(0,0,0,0.8)";
els.rightCtx.shadowBlur = 6;
els.rightCtx.fillText(index + 1, cx - 6, cy + 8);
if (isStitched) {
els.rightCtx.font = "20px system-ui";
els.rightCtx.fillText("🔗", p(box.tr).x - 28, p(box.tr).y + 24);
}
els.rightCtx.shadowBlur = 0;
});
}
// --- MAGNIFIER LOGIC ---
function updateMagnifierState(x, y, active) {
if (!CONFIG.enableMagnifier) return;
isMagnifying = active;
magnifierPos = { x, y };
if (active) {
const lens = els.magnifier;
const w = els.canvas.width;
const h = els.canvas.height;
const px = x * w;
const py = y * h;
let top = py - LENS_SIZE_PX - 50;
if (top < 0) top = py + 50;
lens.style.display = 'block';
lens.style.left = (px - LENS_SIZE_PX / 2) + 'px';
lens.style.top = top + 'px';
lens.style.backgroundSize = `${w * ZOOM_LEVEL}px ${h * ZOOM_LEVEL}px`;
const bgX = (px * ZOOM_LEVEL) - (LENS_SIZE_PX / 2);
const bgY = (py * ZOOM_LEVEL) - (LENS_SIZE_PX / 2);
lens.style.backgroundPosition = `-${bgX}px -${bgY}px`;
} else {
els.magnifier.style.display = 'none';
}
}
// --- INTERACTION ---
function getPos(e, canvas = els.canvas) {
const rect = canvas.getBoundingClientRect();
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
let x = (cx - rect.left) / rect.width;
let y = (cy - rect.top) / rect.height;
return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
}
function isEventOnRightCanvas(e) {
if (!CONFIG.hasRightPage || !els.rightCanvas) return false;
const rect = els.rightCanvas.getBoundingClientRect();
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
return cx >= rect.left && cx <= rect.right && cy >= rect.top && cy <= rect.bottom;
}
function hitTest(x, y, boxArray = boxes, canvas = els.canvas) {
const pad = 30 / canvas.width;
for (let i = boxArray.length - 1; i >= 0; i--) {
const b = boxArray[i];
for (let k of ['tl', 'tr', 'bl', 'br']) {
if (Math.hypot(b[k].x - x, b[k].y - y) < pad) {
return { type: 'corner', index: i, corner: k };
}
}
const mx = Math.min(b.tl.x, b.br.x), Mx = Math.max(b.tl.x, b.br.x);
const my = Math.min(b.tl.y, b.br.y), My = Math.max(b.tl.y, b.br.y);
if (x > mx && x < Mx && y > my && y < My) {
return { type: 'body', index: i };
}
}
return null;
}
function onDown(e) {
if (e.target.closest('#box-toolbar') || e.target.closest('.box-toolbar-secondary')) return;
e.preventDefault();
// Determine which pane is being interacted with
const onRight = isEventOnRightCanvas(e);
activePane = onRight ? 'right' : 'left';
const currentCanvas = onRight ? els.rightCanvas : els.canvas;
const currentBoxes = onRight ? boxesRight : boxes;
const { x, y } = getPos(e, currentCanvas);
updateMagnifierState(x, y, true);
const hit = hitTest(x, y, currentBoxes, currentCanvas);
if (hit) {
dragTarget = { ...hit, pane: activePane };
if (onRight) {
selectedBoxIndexRight = hit.index;
selectedBoxIndex = -1;
startPositions = JSON.parse(JSON.stringify(boxesRight[hit.index]));
} else {
selectedBoxIndex = hit.index;
selectedBoxIndexRight = -1;
startPositions = JSON.parse(JSON.stringify(boxes[hit.index]));
}
startX = x;
startY = y;
updateToolbar();
updateStitchButton();
} else {
if (onRight) {
selectedBoxIndexRight = -1;
if (els.rightToolbar) els.rightToolbar.style.display = 'none';
} else {
selectedBoxIndex = -1;
els.toolbar.style.display = 'none';
}
isDrawing = true;
startX = x;
startY = y;
}
drawBoxes();
if (CONFIG.hasRightPage) drawBoxesRight();
}
function onMove(e) {
if (isDrawing || dragTarget) {
e.preventDefault();
const currentCanvas = activePane === 'right' ? els.rightCanvas : els.canvas;
const { x, y } = getPos(e, currentCanvas);
updateMagnifierState(x, y, true);
const dx = x - startX, dy = y - startY;
if (dragTarget) {
const currentBoxes = dragTarget.pane === 'right' ? boxesRight : boxes;
const b = currentBoxes[dragTarget.index];
if (dragTarget.type === 'corner') {
b[dragTarget.corner].x = x;
b[dragTarget.corner].y = y;
} else {
['tl', 'tr', 'bl', 'br'].forEach(k => {
b[k].x = startPositions[k].x + dx;
b[k].y = startPositions[k].y + dy;
});
}
drawBoxes();
if (CONFIG.hasRightPage) drawBoxesRight();
updateToolbar();
} else if (isDrawing) {
drawBoxes();
if (CONFIG.hasRightPage) drawBoxesRight();
const ctx = activePane === 'right' ? els.rightCtx : els.ctx;
const canvasWidth = activePane === 'right' ? els.rightCanvas.width : els.canvas.width;
const canvasHeight = activePane === 'right' ? els.rightCanvas.height : els.canvas.height;
const sx = startX * canvasWidth, sy = startY * canvasHeight;
const w = (x - startX) * canvasWidth, h = (y - startY) * canvasHeight;
ctx.strokeStyle = 'rgba(255, 77, 77, 0.5)';
ctx.strokeRect(sx, sy, w, h);
}
}
}
function onUp(e) {
updateMagnifierState(0, 0, false);
if (isDrawing) {
const currentCanvas = activePane === 'right' ? els.rightCanvas : els.canvas;
const currentBoxes = activePane === 'right' ? boxesRight : boxes;
const rect = currentCanvas.getBoundingClientRect();
const cx = e.changedTouches ? e.changedTouches[0].clientX : e.clientX;
const cy = e.changedTouches ? e.changedTouches[0].clientY : e.clientY;
let endX = Math.max(0, Math.min(1, (cx - rect.left) / rect.width));
let endY = Math.max(0, Math.min(1, (cy - rect.top) / rect.height));
if (Math.abs(endX - startX) * currentCanvas.width > 20) {
currentBoxes.push({
id: Date.now(),
tl: { x: Math.min(startX, endX), y: Math.min(startY, endY) },
tr: { x: Math.max(startX, endX), y: Math.min(startY, endY) },
bl: { x: Math.min(startX, endX), y: Math.max(startY, endY) },
br: { x: Math.max(startX, endX), y: Math.max(startY, endY) },
remote_stitch_source: null
});
if (activePane === 'right') {
selectedBoxIndexRight = boxesRight.length - 1;
} else {
selectedBoxIndex = boxes.length - 1;
}
}
}
isDrawing = false;
dragTarget = null;
saveBoxes();
if (CONFIG.hasRightPage) saveBoxesRight();
drawBoxes();
if (CONFIG.hasRightPage) drawBoxesRight();
updateToolbar();
updateStitchButton();
}
// --- HELPERS ---
function setupListeners() {
els.canvas.addEventListener('mousedown', onDown);
els.canvas.addEventListener('touchstart', onDown, { passive: false });
// Add listeners for right canvas in two-page mode
if (CONFIG.hasRightPage && els.rightCanvas) {
els.rightCanvas.addEventListener('mousedown', onDown);
els.rightCanvas.addEventListener('touchstart', onDown, { passive: false });
}
document.addEventListener('mousemove', onMove);
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('mouseup', onUp);
document.addEventListener('touchend', onUp);
document.getElementById('backBtn').onclick = () => {
if (CONFIG.imageIndex > 0) {
navigate(CONFIG.imageIndex - 1);
} else {
location.href = '/v2';
}
};
document.getElementById('clearBtn').onclick = () => {
const msg = CONFIG.twoPageMode ? "Clear all boxes on both pages?" : "Clear all boxes?";
if (confirm(msg)) {
boxes = [];
selectedBoxIndex = -1;
saveBoxes();
drawBoxes();
els.toolbar.style.display = 'none';
if (CONFIG.hasRightPage) {
boxesRight = [];
selectedBoxIndexRight = -1;
saveBoxesRight();
drawBoxesRight();
if (els.rightToolbar) els.rightToolbar.style.display = 'none';
}
}
};
document.getElementById('delete-btn').onclick = (e) => {
e.stopPropagation();
if (activePane === 'right' && selectedBoxIndexRight > -1 && CONFIG.hasRightPage) {
boxesRight.splice(selectedBoxIndexRight, 1);
selectedBoxIndexRight = -1;
if (els.rightToolbar) els.rightToolbar.style.display = 'none';
saveBoxesRight();
drawBoxesRight();
} else {
boxes.splice(selectedBoxIndex, 1);
selectedBoxIndex = -1;
els.toolbar.style.display = 'none';
saveBoxes();
drawBoxes();
}
};
document.getElementById('move-up-btn').onclick = (e) => {
e.stopPropagation();
if (activePane === 'right' && selectedBoxIndexRight > -1 && CONFIG.hasRightPage) {
if (selectedBoxIndexRight < boxesRight.length - 1) {
const b = boxesRight.splice(selectedBoxIndexRight, 1)[0];
boxesRight.splice(selectedBoxIndexRight + 1, 0, b);
selectedBoxIndexRight++;
saveBoxesRight();
drawBoxesRight();
updateToolbar();
}
} else if (selectedBoxIndex < boxes.length - 1) {
const b = boxes.splice(selectedBoxIndex, 1)[0];
boxes.splice(selectedBoxIndex + 1, 0, b);
selectedBoxIndex++;
saveBoxes();
drawBoxes();
updateToolbar();
}
};
document.getElementById('move-down-btn').onclick = (e) => {
e.stopPropagation();
if (activePane === 'right' && selectedBoxIndexRight > -1 && CONFIG.hasRightPage) {
if (selectedBoxIndexRight > 0) {
const b = boxesRight.splice(selectedBoxIndexRight, 1)[0];
boxesRight.splice(selectedBoxIndexRight - 1, 0, b);
selectedBoxIndexRight--;
saveBoxesRight();
drawBoxesRight();
updateToolbar();
}
} else if (selectedBoxIndex > 0) {
const b = boxes.splice(selectedBoxIndex, 1)[0];
boxes.splice(selectedBoxIndex - 1, 0, b);
selectedBoxIndex--;
saveBoxes();
drawBoxes();
updateToolbar();
}
};
document.getElementById('stitch-btn').onclick = handleStitch;
document.getElementById('processBtn').onclick = processPage;
// Setup listeners for right page toolbar buttons
if (CONFIG.hasRightPage) {
document.getElementById('delete-btn-right').onclick = (e) => {
e.stopPropagation();
boxesRight.splice(selectedBoxIndexRight, 1);
selectedBoxIndexRight = -1;
els.rightToolbar.style.display = 'none';
saveBoxesRight();
drawBoxesRight();
};
document.getElementById('move-up-btn-right').onclick = (e) => {
e.stopPropagation();
if (selectedBoxIndexRight < boxesRight.length - 1) {
const b = boxesRight.splice(selectedBoxIndexRight, 1)[0];
boxesRight.splice(selectedBoxIndexRight + 1, 0, b);
selectedBoxIndexRight++;
saveBoxesRight();
drawBoxesRight();
updateToolbar();
}
};
document.getElementById('move-down-btn-right').onclick = (e) => {
e.stopPropagation();
if (selectedBoxIndexRight > 0) {
const b = boxesRight.splice(selectedBoxIndexRight, 1)[0];
boxesRight.splice(selectedBoxIndexRight - 1, 0, b);
selectedBoxIndexRight--;
saveBoxesRight();
drawBoxesRight();
updateToolbar();
}
};
document.getElementById('stitch-btn-right').onclick = handleStitch;
}
// Data Entry
document.getElementById('dataToggle').onclick = () => {
const panel = document.getElementById('dataPanel');
const filterPanel = document.getElementById('filtersPanel');
// Close filter panel if open
if (filterPanel.classList.contains('show')) {
filterPanel.classList.remove('show');
}
if (!panel.classList.contains('show') && selectedBoxIndex === -1 && boxes.length > 0) {
selectedBoxIndex = 0;
updateToolbar();
drawBoxes();
}
panel.classList.toggle('show');
};
const dataFields = {
'box-q-num': 'question_number',
'box-status': 'status',
'box-marked': 'marked_solution',
'box-actual': 'actual_solution'
};
Object.keys(dataFields).forEach(id => {
document.getElementById(id).addEventListener('input', (e) => {
if (activePane === 'right' && selectedBoxIndexRight > -1 && CONFIG.hasRightPage) {
boxesRight[selectedBoxIndexRight][dataFields[id]] = e.target.value;
saveBoxesRight();
} else if (selectedBoxIndex > -1) {
boxes[selectedBoxIndex][dataFields[id]] = e.target.value;
saveBoxes();
}
});
});
// Keyboard Shortcuts
document.addEventListener('keydown', (e) => {
if (!e.shiftKey) return;
const key = e.key.toLowerCase();
if (e.key === 'ArrowRight') {
e.preventDefault();
document.getElementById('processBtn').click();
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
document.getElementById('backBtn').click();
return;
}
if (key === 'q') {
e.preventDefault();
if (boxes.length > 0) {
selectedBoxIndex = (selectedBoxIndex + 1) % boxes.length;
updateToolbar();
drawBoxes();
}
return;
}
if (selectedBoxIndex === -1) return;
if (key === 'm') {
e.preventDefault();
document.getElementById('box-marked').focus();
} else if (key === 'a') {
e.preventDefault();
document.getElementById('box-actual').focus();
} else if (key === 'c') {
e.preventDefault();
document.getElementById('box-status').value = 'correct';
boxes[selectedBoxIndex].status = 'correct';
saveBoxes();
} else if (key === 'w') {
e.preventDefault();
document.getElementById('box-status').value = 'wrong';
boxes[selectedBoxIndex].status = 'wrong';
saveBoxes();
} else if (key === 'u') {
e.preventDefault();
document.getElementById('box-status').value = 'unattempted';
boxes[selectedBoxIndex].status = 'unattempted';
saveBoxes();
}
});
// Filters
document.getElementById('filterToggle').onclick = () => {
const panel = document.getElementById('filtersPanel');
const dataPanel = document.getElementById('dataPanel');
// Close data panel if open
if (dataPanel.classList.contains('show')) {
dataPanel.classList.remove('show');
}
panel.classList.toggle('show');
};
['brightness', 'contrast', 'gamma'].forEach(id => {
document.getElementById(id).addEventListener('input', updateFilters);
});
// Browser history navigation
window.addEventListener('popstate', (e) => {
if (e.state && typeof e.state.imageIndex !== 'undefined') {
navigate(e.state.imageIndex, false);
} else {
// Fallback for initial page or non-state pops
const parts = window.location.pathname.split('/');
const idx = parseInt(parts[parts.length - 1]);
if (!isNaN(idx)) navigate(idx, false);
}
});
// Thumbnail Click Navigation
document.querySelectorAll('.thumb-item').forEach(thumb => {
thumb.addEventListener('click', () => {
const pageIndex = parseInt(thumb.getAttribute('data-page-index'));
navigate(pageIndex);
});
});
}
function updateDataPanel() {
// Get the selected box from either pane
let b = null;
if (activePane === 'right' && selectedBoxIndexRight > -1 && CONFIG.hasRightPage) {
b = boxesRight[selectedBoxIndexRight];
} else if (selectedBoxIndex > -1) {
b = boxes[selectedBoxIndex];
}
const msg = document.getElementById('no-selection-msg');
const form = document.getElementById('data-form');
if (!b) {
if (msg) msg.style.display = 'block';
if (form) form.style.display = 'none';
return;
}
if (msg) msg.style.display = 'none';
if (form) form.style.display = 'block';
document.getElementById('box-q-num').value = b.question_number || '';
document.getElementById('box-status').value = b.status || 'unattempted';
document.getElementById('box-marked').value = b.marked_solution || '';
document.getElementById('box-actual').value = b.actual_solution || '';
}
function updateToolbar() {
updateDataPanel();
// Handle left toolbar
if (selectedBoxIndex === -1 || activePane !== 'left') {
els.toolbar.classList.remove('show');
setTimeout(() => { if (!els.toolbar.classList.contains('show')) els.toolbar.style.display = 'none'; }, 200);
} else {
const b = boxes[selectedBoxIndex];
const p = (pt) => ({ x: pt.x * els.canvas.width, y: pt.y * els.canvas.height });
const maxX = Math.max(p(b.tr).x, p(b.br).x);
const minY = Math.min(p(b.tl).y, p(b.tr).y);
let left = maxX - 180;
if (left < 0) left = 0;
let top = minY + 10;
els.toolbar.style.left = `${left}px`;
els.toolbar.style.top = `${top}px`;
els.toolbar.style.display = 'flex';
requestAnimationFrame(() => els.toolbar.classList.add('show'));
}
// Handle right toolbar
if (CONFIG.hasRightPage && els.rightToolbar) {
if (selectedBoxIndexRight === -1 || activePane !== 'right') {
els.rightToolbar.classList.remove('show');
setTimeout(() => { if (!els.rightToolbar.classList.contains('show')) els.rightToolbar.style.display = 'none'; }, 200);
} else {
const b = boxesRight[selectedBoxIndexRight];
const p = (pt) => ({ x: pt.x * els.rightCanvas.width, y: pt.y * els.rightCanvas.height });
const maxX = Math.max(p(b.tr).x, p(b.br).x);
const minY = Math.min(p(b.tl).y, p(b.tr).y);
let left = maxX - 180;
if (left < 0) left = 0;
let top = minY + 10;
els.rightToolbar.style.left = `${left}px`;
els.rightToolbar.style.top = `${top}px`;
els.rightToolbar.style.display = 'flex';
requestAnimationFrame(() => els.rightToolbar.classList.add('show'));
}
}
}
function updateStitchButton() {
const btn = document.getElementById('stitch-btn');
const icon = btn.querySelector('i');
const isBuffer = stitchBuffer && stitchBuffer.session_id === CONFIG.sessionId;
// Check if current selection is stitched (either left or right page)
let isStitched = false;
if (activePane === 'right' && selectedBoxIndexRight > -1 && CONFIG.hasRightPage) {
isStitched = boxesRight[selectedBoxIndexRight]?.remote_stitch_source;
} else if (selectedBoxIndex > -1) {
isStitched = boxes[selectedBoxIndex]?.remote_stitch_source;
}
if (isBuffer) {
icon.className = 'bi bi-link-45deg';
btn.style.color = '#0dcaf0';
} else if (isStitched) {
icon.className = 'bi bi-x-lg';
btn.style.color = '#dc3545';
} else {
icon.className = 'bi bi-scissors';
btn.style.color = '#e9ecef';
}
// Update right stitch button too if present
if (CONFIG.hasRightPage) {
const btnRight = document.getElementById('stitch-btn-right');
if (btnRight) {
const iconRight = btnRight.querySelector('i');
const isStitchedRight = selectedBoxIndexRight > -1 && boxesRight[selectedBoxIndexRight]?.remote_stitch_source;
if (isBuffer) {
iconRight.className = 'bi bi-link-45deg';
btnRight.style.color = '#0dcaf0';
} else if (isStitchedRight) {
iconRight.className = 'bi bi-x-lg';
btnRight.style.color = '#dc3545';
} else {
iconRight.className = 'bi bi-scissors';
btnRight.style.color = '#e9ecef';
}
}
}
}
function handleStitch(e) {
e.stopPropagation();
// Determine which box is selected
let b, currentBoxes, currentPageIndex, saveFunc, drawFunc;
if (activePane === 'right' && selectedBoxIndexRight > -1 && CONFIG.hasRightPage) {
b = boxesRight[selectedBoxIndexRight];
currentBoxes = boxesRight;
currentPageIndex = CONFIG.rightPageIndex;
saveFunc = saveBoxesRight;
drawFunc = drawBoxesRight;
} else if (selectedBoxIndex > -1) {
b = boxes[selectedBoxIndex];
currentBoxes = boxes;
currentPageIndex = CONFIG.leftPageIndex;
saveFunc = saveBoxes;
drawFunc = drawBoxes;
} else {
return;
}
if (stitchBuffer && stitchBuffer.session_id === CONFIG.sessionId) {
b.remote_stitch_source = {
page_index: stitchBuffer.page_index,
box: stitchBuffer.box
};
stitchBuffer = null;
localStorage.removeItem('gemini_stitch_buffer');
toast('Boxes Linked!');
} else if (b.remote_stitch_source) {
b.remote_stitch_source = null;
toast('Link Removed');
} else {
const minX = Math.min(b.tl.x, b.bl.x), minY = Math.min(b.tl.y, b.tr.y);
const maxX = Math.max(b.tr.x, b.br.x), maxY = Math.max(b.bl.y, b.br.y);
const cleanBox = { ...b, x: minX, y: minY, w: maxX - minX, h: maxY - minY };
stitchBuffer = {
session_id: CONFIG.sessionId,
page_index: currentPageIndex,
box: cleanBox
};
localStorage.setItem('gemini_stitch_buffer', JSON.stringify(stitchBuffer));
toast('Copied!');
}
saveFunc();
updateStitchButton();
drawBoxes();
if (CONFIG.hasRightPage) drawBoxesRight();
}
function toast(msg) {
const t = document.createElement('div');
t.className = 'toast align-items-center show fade p-2 rounded-3';
t.innerHTML = `<div class="d-flex"><div class="toast-body">${msg}</div></div>`;
document.getElementById('toastContainer').appendChild(t);
setTimeout(() => t.remove(), 2500);
}
async function navigate(newIndex, pushState = true) {
if (newIndex < 0 || newIndex >= CONFIG.totalPages) {
if (newIndex >= CONFIG.totalPages) {
location.href = `/question_entry_v2/${CONFIG.sessionId}`;
}
return;
}
// Save current draft boxes to localStorage
saveBoxes();
if (CONFIG.twoPageMode) saveBoxesRight();
// Update CONFIG
CONFIG.imageIndex = newIndex;
if (CONFIG.twoPageMode) {
CONFIG.leftPageIndex = newIndex * 2;
CONFIG.rightPageIndex = CONFIG.leftPageIndex + 1;
CONFIG.hasRightPage = CONFIG.rightPageIndex < CONFIG.allPages.length;
} else {
CONFIG.leftPageIndex = newIndex;
}
// Update storage keys
storageKey = CONFIG.userId ? `cropState_${CONFIG.userId}_${CONFIG.sessionId}_${CONFIG.leftPageIndex}` : `cropState_${CONFIG.sessionId}_${CONFIG.leftPageIndex}`;
if (CONFIG.twoPageMode) {
storageKeyRight = CONFIG.userId ? `cropState_${CONFIG.userId}_${CONFIG.sessionId}_${CONFIG.rightPageIndex}` : `cropState_${CONFIG.sessionId}_${CONFIG.rightPageIndex}`;
}
// Update UI
updateNavigationUI();
// Load new images
els.image.classList.remove('loaded');
const leftUrl = `/image/upload/${CONFIG.allPages[CONFIG.leftPageIndex].filename}`;
loadMainImageWithProgress(leftUrl);
if (CONFIG.twoPageMode && CONFIG.hasRightPage) {
els.rightImage.classList.remove('loaded');
const rightUrl = `/image/upload/${CONFIG.allPages[CONFIG.rightPageIndex].filename}`;
loadRightImageWithProgress(rightUrl);
}
// Load boxes for new page
boxes = [];
boxesRight = [];
loadBoxes();
if (CONFIG.twoPageMode && CONFIG.hasRightPage) loadBoxesRight();
// Reset selection
selectedBoxIndex = -1;
selectedBoxIndexRight = -1;
activePane = 'left';
updateToolbar();
drawBoxes();
if (CONFIG.twoPageMode) drawBoxesRight();
// Update URL without reload
if (pushState) {
window.history.pushState({ imageIndex: newIndex }, '', `/cropv2/${CONFIG.sessionId}/${newIndex}`);
}
}
function updateNavigationUI() {
// Update Title
const titleEl = document.getElementById('app-header-title');
if (CONFIG.twoPageMode) {
const nextRightLabel = CONFIG.hasRightPage ? `-${CONFIG.rightPageIndex + 1}` : '';
const actualTotal = CONFIG.allPages.length;
titleEl.innerHTML = `<i class="bi bi-bounding-box me-2"></i>Pages ${CONFIG.leftPageIndex + 1}${nextRightLabel} / ${actualTotal}`;
} else {
titleEl.innerHTML = `<i class="bi bi-bounding-box me-2"></i>Page ${CONFIG.imageIndex + 1} / ${CONFIG.totalPages}`;
}
// Update Labels & Visibility
if (CONFIG.twoPageMode) {
document.getElementById('page-label-left').textContent = `Page ${CONFIG.leftPageIndex + 1}`;
if (CONFIG.hasRightPage) {
document.getElementById('page-label-right').textContent = `Page ${CONFIG.rightPageIndex + 1}`;
els.rightCropArea.style.display = 'block';
} else {
els.rightCropArea.style.display = 'none';
}
}
// Update Thumbnails
document.querySelectorAll('.thumb-item').forEach(thumb => {
const idx = parseInt(thumb.getAttribute('data-page-index'));
if (idx === CONFIG.imageIndex) {
thumb.classList.add('active');
thumb.scrollIntoView({ inline: 'center', behavior: 'smooth' });
} else {
thumb.classList.remove('active');
}
});
}
async function processPage() {
const hasLeftBoxes = boxes.length > 0;
const hasRightBoxes = CONFIG.twoPageMode && boxesRight.length > 0;
const currentImageIndex = CONFIG.imageIndex;
const currentLeftPageIndex = CONFIG.leftPageIndex;
const currentRightPageIndex = CONFIG.rightPageIndex;
const backgroundRequests = [];
if (!hasLeftBoxes && !hasRightBoxes) {
toast('Skipping page(s)...');
navigate(currentImageIndex + 1);
return;
}
try {
// Process left page
if (hasLeftBoxes || !CONFIG.twoPageMode) {
const finalBoxes = boxes.map(b => ({
...b,
x: Math.min(b.tl.x, b.bl.x),
y: Math.min(b.tl.y, b.tr.y),
w: Math.max(b.tr.x, b.br.x) - Math.min(b.tl.x, b.bl.x),
h: Math.max(b.bl.y, b.br.y) - Math.min(b.tl.y, b.tr.y)
}));
const cv = document.createElement('canvas');
cv.width = els.image.naturalWidth;
cv.height = els.image.naturalHeight;
const c = cv.getContext('2d');
c.filter = els.image.style.filter;
c.drawImage(els.image, 0, 0);
backgroundRequests.push(fetch('/process_crop_v2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: CONFIG.sessionId,
image_index: currentLeftPageIndex,
boxes: finalBoxes,
imageData: cv.toDataURL('image/jpeg', 0.85)
})
}).then(async res => {
if (!res.ok) throw new Error(await res.text());
}));
}
// Process right page if in two-page mode
if (CONFIG.twoPageMode && (hasRightBoxes || CONFIG.twoPageMode) && CONFIG.hasRightPage) {
const finalBoxesRight = boxesRight.map(b => ({
...b,
x: Math.min(b.tl.x, b.bl.x),
y: Math.min(b.tl.y, b.tr.y),
w: Math.max(b.tr.x, b.br.x) - Math.min(b.tl.x, b.bl.x),
h: Math.max(b.bl.y, b.br.y) - Math.min(b.tl.y, b.tr.y)
}));
const cvRight = document.createElement('canvas');
cvRight.width = els.rightImage.naturalWidth;
cvRight.height = els.rightImage.naturalHeight;
const cRight = cvRight.getContext('2d');
cRight.filter = els.rightImage.style.filter;
cRight.drawImage(els.rightImage, 0, 0);
backgroundRequests.push(fetch('/process_crop_v2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: CONFIG.sessionId,
image_index: currentRightPageIndex,
boxes: finalBoxesRight,
imageData: cvRight.toDataURL('image/jpeg', 0.85)
})
}).then(async res => {
if (!res.ok) throw new Error(await res.text());
}));
}
navigate(currentImageIndex + 1);
Promise.all(backgroundRequests)
.then(() => {
const savedLabel = CONFIG.twoPageMode && currentRightPageIndex < CONFIG.allPages.length
? `Pages ${currentLeftPageIndex + 1}-${currentRightPageIndex + 1} saved`
: `Page ${currentLeftPageIndex + 1} saved`;
toast(savedLabel);
})
.catch((e) => {
toast(`Save failed: ${e.message}`);
});
} catch (e) {
toast(`Save failed: ${e.message}`);
}
}
function saveBoxes() {
localStorage.setItem(storageKey, JSON.stringify(boxes));
}
function saveBoxesRight() {
if (storageKeyRight) {
localStorage.setItem(storageKeyRight, JSON.stringify(boxesRight));
}
}
function loadBoxes() {
try {
const s = localStorage.getItem(storageKey);
if (s) {
boxes = JSON.parse(s).map(b => b.tl ? b : {
id: b.id || Date.now(),
tl: { x: b.x, y: b.y },
tr: { x: b.x + b.w, y: b.y },
bl: { x: b.x, y: b.y + b.h },
br: { x: b.x + b.w, y: b.y + b.h },
remote_stitch_source: b.remote_stitch_source
});
drawBoxes();
}
} catch (e) {}
}
function loadBoxesRight() {
if (!storageKeyRight) return;
try {
const s = localStorage.getItem(storageKeyRight);
if (s) {
boxesRight = JSON.parse(s).map(b => b.tl ? b : {
id: b.id || Date.now(),
tl: { x: b.x, y: b.y },
tr: { x: b.x + b.w, y: b.y },
bl: { x: b.x, y: b.y + b.h },
br: { x: b.x + b.w, y: b.y + b.h },
remote_stitch_source: b.remote_stitch_source
});
drawBoxesRight();
}
} catch (e) {}
}
function updateFilters() {
const b = document.getElementById('brightness').value;
const c = document.getElementById('contrast').value;
const g = document.getElementById('gamma').value;
document.getElementById('val-b').innerText = b;
document.getElementById('val-c').innerText = c;
document.getElementById('val-g').innerText = g;
const filterValue = `brightness(${100 + parseFloat(b)}%) contrast(${c})`;
els.image.style.filter = filterValue;
els.magnifier.style.filter = filterValue;
// Apply filters to right page in two-page mode
if (CONFIG.hasRightPage && els.rightImage) {
els.rightImage.style.filter = filterValue;
if (els.rightMagnifier) {
els.rightMagnifier.style.filter = filterValue;
}
}
localStorage.setItem('pdfFilters', JSON.stringify({ b, c, g }));
}
function loadSettings() {
const s = JSON.parse(localStorage.getItem('pdfFilters') || '{}');
if (s.b) document.getElementById('brightness').value = s.b;
if (s.c) document.getElementById('contrast').value = s.c;
if (s.g) document.getElementById('gamma').value = s.g;
updateFilters();
}
init();
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>