t commited on
Commit
9ac61d7
·
1 Parent(s): 92ef226

updated cropv2

Browse files
Files changed (1) hide show
  1. templates/cropv2.html +696 -183
templates/cropv2.html CHANGED
@@ -13,7 +13,7 @@
13
  --slider-height: 90px;
14
  --bg-dark: #121212;
15
  --surface-color: #1e1e1e;
16
- --lens-size: 140px; /* Size of the magnifier DOM element */
17
  }
18
 
19
  html, body {
@@ -29,6 +29,28 @@
29
  flex-direction: column;
30
  }
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  /* --- 1. HEADER --- */
33
  .app-header {
34
  height: var(--header-height);
@@ -88,9 +110,13 @@
88
  overflow: hidden;
89
  }
90
 
91
- /* Crosshair inside magnifier */
92
  #magnifier::after {
93
- content: ''; position: absolute; top: 50%; left: 50%; width: 12px; height: 12px;
 
 
 
 
 
94
  border-left: 2px solid rgba(255,50,50,0.8);
95
  border-top: 2px solid rgba(255,50,50,0.8);
96
  transform: translate(-50%, -50%);
@@ -98,35 +124,191 @@
98
 
99
  /* --- 3. THUMBNAILS --- */
100
  .thumbnail-bar {
101
- height: var(--slider-height); flex-shrink: 0; background: #212529; 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;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  }
103
- .thumb-item { height: 60px; min-width: 45px; border-radius: 6px; overflow: hidden; position: relative; border: 2px solid transparent; opacity: 0.6; background: #000; flex-shrink: 0; }
104
- .thumb-item.active { border-color: #0d6efd; opacity: 1; transform: scale(1.05); }
105
- .thumb-item img { width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity 0.3s ease-in; }
106
  .thumb-item img.loaded { opacity: 1; }
107
- .thumb-number { position: absolute; bottom: 0; right: 0; background: rgba(0,0,0,0.7); color: #fff; font-size: 10px; padding: 2px 4px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
  /* --- FLOATING UI --- */
110
- #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(5px); z-index: 100; box-shadow: 0 4px 12px rgba(0,0,0,0.3); }
111
- #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; }
112
- #box-toolbar button:active { transform: scale(0.9); color: #fff; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  #box-toolbar button.delete-btn { color: #dc3545; }
114
 
115
- .fab-container { position: absolute; bottom: 20px; right: 20px; z-index: 45; }
116
- .fab-btn { width: 48px; height: 48px; border-radius: 50%; background: #212529; border: 1px solid #495057; color: white; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
- .filters-panel { position: absolute; bottom: 60px; right: 0; background: #212529; border-radius: 12px; padding: 16px; width: 220px; box-shadow: 0 5px 20px rgba(0,0,0,0.6); display: none; border: 1px solid #495057; }
 
 
 
 
 
 
 
 
 
 
 
119
  .filters-panel.show { display: block; }
 
 
 
 
 
 
 
 
 
 
 
120
  .form-range { height: 4px; }
121
 
122
- #loader-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: none; align-items: center; justify-content: center; z-index: 9999; }
123
- .toast-container { position: fixed; top: 80px; left: 50%; transform: translateX(-50%); z-index: 2000; width: auto; pointer-events: none; }
124
- .toast { background: #333; color: white; border: 1px solid #555; pointer-events: auto; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  </style>
126
  </head>
127
  <body>
128
  {% include '_navbar.html' %}
129
 
 
 
 
130
  <header class="app-header">
131
  <h1 class="header-title"><i class="bi bi-bounding-box me-2"></i>Page {{ image_index + 1 }} / {{ total_pages }}</h1>
132
  <div class="header-actions">
@@ -151,39 +333,67 @@
151
  </div>
152
  </div>
153
 
154
- <!-- Floating Actions -->
155
- <div class="fab-container">
156
- <div class="filters-panel" id="dataPanel" style="bottom: 130px;">
157
- <div id="no-selection-msg" class="text-center text-muted small py-3">Select a box to edit</div>
158
- <div id="data-form">
159
- <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>
160
- <div class="mb-2"><label class="small text-secondary">Status</label>
161
- <select class="form-select form-select-sm bg-dark text-white border-secondary" id="box-status">
162
- <option value="unattempted">Unattempted</option>
163
- <option value="correct">Correct</option>
164
- <option value="wrong">Wrong</option>
165
- </select>
166
- </div>
167
- <div class="row g-2 mb-0">
168
- <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>
169
- <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>
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  </div>
171
- </div>
172
- </div>
173
- <button class="fab-btn" id="dataToggle" title="Data Entry"><i class="bi bi-pencil-square"></i></button>
174
 
175
- <div class="filters-panel" id="filtersPanel">
176
- <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>
177
- <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>
178
- <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>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  </div>
180
- <button class="fab-btn" id="filterToggle"><i class="bi bi-sliders"></i></button>
181
  </div>
182
  </div>
183
 
184
  <div class="thumbnail-bar">
185
  {% for page in all_pages %}
186
- <div class="thumb-item {% if page.image_index == image_index %}active{% endif %}" onclick="location.href='/cropv2/{{ session_id }}/{{ page.image_index }}'">
 
187
  <img data-src="/image/upload/{{ page.filename }}"
188
  alt="Page {{ page.image_index + 1 }}"
189
  data-session="{{ session_id }}"
@@ -208,54 +418,155 @@
208
  };
209
  const storageKey = CONFIG.userId ? `cropState_${CONFIG.userId}_${CONFIG.sessionId}_${CONFIG.imageIndex}` : `cropState_${CONFIG.sessionId}_${CONFIG.imageIndex}`;
210
 
211
- // --- INDEXED DB CACHE ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  const ThumbCache = {
213
- DB_NAME: 'PDF_Crop_Thumbs', STORE_NAME: 'images', EXPIRY_MS: 2 * 24 * 60 * 60 * 1000,
 
 
 
 
214
  async open() {
215
  return new Promise((resolve, reject) => {
216
  const req = indexedDB.open(this.DB_NAME, 1);
217
- req.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains(this.STORE_NAME)) db.createObjectStore(this.STORE_NAME, { keyPath: 'url' }); };
218
- req.onsuccess = (e) => resolve(e.target.result); req.onerror = (e) => reject(e);
 
 
 
 
 
 
219
  });
220
  },
 
221
  async cleanup() {
222
  const db = await this.open();
223
  return new Promise((resolve) => {
224
- const tx = db.transaction(this.STORE_NAME, 'readwrite'); const store = tx.objectStore(this.STORE_NAME); const now = Date.now();
 
 
225
  const req = store.openCursor();
226
- req.onsuccess = (e) => { const cursor = e.target.result; if (cursor) { if ((now - cursor.value.timestamp) > this.EXPIRY_MS) cursor.delete(); cursor.continue(); } else resolve(); };
 
 
 
 
 
 
 
 
 
 
 
227
  req.onerror = () => resolve();
228
  });
229
  },
230
- async loadImage(url, imgElement) {
 
231
  try {
232
- const db = await this.open(); const txRead = db.transaction(this.STORE_NAME, 'readonly');
 
233
  const req = txRead.objectStore(this.STORE_NAME).get(url);
 
234
  req.onsuccess = async (e) => {
235
  const record = e.target.result;
236
- if (record) { imgElement.src = URL.createObjectURL(record.blob); imgElement.classList.add('loaded'); }
237
- else {
 
 
 
238
  try {
239
- const response = await fetch(url); const blob = await response.blob();
240
- const txWrite = db.transaction(this.STORE_NAME, 'readwrite'); txWrite.objectStore(this.STORE_NAME).put({ url: url, blob: blob, timestamp: Date.now(), sessionId: CONFIG.sessionId });
241
- imgElement.src = URL.createObjectURL(blob); imgElement.classList.add('loaded');
242
- } catch (err) { imgElement.src = url; imgElement.classList.add('loaded'); }
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  }
244
  };
245
- } catch (err) { imgElement.src = url; imgElement.classList.add('loaded'); }
 
 
 
 
246
  },
247
- async initAllThumbs() {
248
- await this.cleanup(); const images = document.querySelectorAll('.thumb-img');
249
- const process = (img) => { const url = img.getAttribute('data-src'); if(url) this.loadImage(url, img); };
250
- if ('requestIdleCallback' in window) images.forEach(img => requestIdleCallback(() => process(img))); else images.forEach(img => setTimeout(() => process(img), 10));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  }
252
  };
253
 
254
  // --- APP LOGIC ---
255
  const els = {
256
- image: document.getElementById('main-image'), imagePane: document.getElementById('imagePane'),
257
- cropArea: document.getElementById('crop-area'), canvas: document.getElementById('draw-canvas'),
258
- ctx: document.getElementById('draw-canvas').getContext('2d'), toolbar: document.getElementById('box-toolbar'),
 
 
 
259
  magnifier: document.getElementById('magnifier')
260
  };
261
 
@@ -263,35 +574,49 @@
263
  let selectedBoxIndex = -1;
264
  let isDrawing = false;
265
  let startX, startY;
266
- let dragTarget = null;
267
  let startPositions = {};
268
  let stitchBuffer = JSON.parse(localStorage.getItem('gemini_stitch_buffer') || 'null');
269
 
270
  // ZOOM STATE
271
- const LENS_SIZE_PX = 140; // Must match CSS
272
  const ZOOM_LEVEL = 2.5;
273
  let isMagnifying = false;
274
- let magnifierPos = { x: 0, y: 0 }; // Normalized 0-1
275
 
276
  function init() {
277
- ThumbCache.initAllThumbs();
278
- if (els.image.complete && els.image.naturalWidth > 0) fitImage(); else els.image.onload = fitImage;
279
- const ro = new ResizeObserver(() => requestAnimationFrame(fitImage)); ro.observe(els.imagePane);
280
- loadSettings(); loadBoxes(); setupListeners(); updateStitchButton();
281
- const active = document.querySelector('.thumb-item.active'); if(active) active.scrollIntoView({inline:'center'});
 
 
 
 
 
 
 
 
 
282
  }
283
 
284
  function fitImage() {
285
  if (!els.image.naturalWidth) return;
286
  const rect = els.imagePane.getBoundingClientRect();
287
  const padding = 10;
288
- const availableW = rect.width - padding; const availableH = rect.height - padding;
 
289
  const scale = Math.min(availableW / els.image.naturalWidth, availableH / els.image.naturalHeight);
290
 
291
- const finalW = Math.floor(els.image.naturalWidth * scale); const finalH = Math.floor(els.image.naturalHeight * scale);
292
- els.cropArea.style.width = `${finalW}px`; els.cropArea.style.height = `${finalH}px`;
293
- els.image.style.width = `${finalW}px`; els.image.style.height = `${finalH}px`;
294
- els.canvas.width = finalW; els.canvas.height = finalH;
 
 
 
 
295
 
296
  if (CONFIG.enableMagnifier) {
297
  els.magnifier.style.backgroundImage = `url('${els.image.src}')`;
@@ -305,7 +630,6 @@
305
  function drawBoxes() {
306
  els.ctx.clearRect(0, 0, els.canvas.width, els.canvas.height);
307
 
308
- // 1. Draw Boxes
309
  boxes.forEach((box, index) => {
310
  const isSelected = index === selectedBoxIndex;
311
  const isStitched = box.remote_stitch_source != null;
@@ -316,45 +640,53 @@
316
  els.ctx.fillStyle = isSelected ? 'rgba(255, 77, 77, 0.15)' : (isStitched ? 'rgba(13, 202, 240, 0.2)' : 'rgba(255, 193, 7, 0.1)');
317
 
318
  els.ctx.beginPath();
319
- els.ctx.moveTo(p(box.tl).x, p(box.tl).y); els.ctx.lineTo(p(box.tr).x, p(box.tr).y);
320
- els.ctx.lineTo(p(box.br).x, p(box.br).y); els.ctx.lineTo(p(box.bl).x, p(box.bl).y);
321
- els.ctx.closePath(); els.ctx.stroke(); els.ctx.fill();
 
 
 
 
322
 
323
  if (isSelected) {
324
  els.ctx.fillStyle = 'white';
325
- ['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(); });
 
 
 
 
 
326
  }
327
 
328
- const cx = (p(box.tl).x + p(box.br).x)/2; const cy = (p(box.tl).y + p(box.br).y)/2;
329
- els.ctx.font = "bold 24px system-ui"; els.ctx.fillStyle = "white"; els.ctx.shadowColor="rgba(0,0,0,0.8)"; els.ctx.shadowBlur=6;
 
 
 
 
330
  els.ctx.fillText(index + 1, cx - 6, cy + 8);
331
- if(isStitched) { els.ctx.font = "20px system-ui"; els.ctx.fillText("🔗", p(box.tr).x - 28, p(box.tr).y + 24); }
332
- els.ctx.shadowBlur=0;
 
 
 
333
  });
334
 
335
- // 2. Draw Magnifier Source Region Indicator
336
  if (isMagnifying && CONFIG.enableMagnifier) {
337
  const px = magnifierPos.x * els.canvas.width;
338
  const py = magnifierPos.y * els.canvas.height;
339
-
340
- // Calculate radius on canvas that matches the lens view
341
- // Lens is 140px on screen. Zoom is 2.5x.
342
- // So the source circle diameter is 140 / 2.5 = 56px. Radius = 28px.
343
  const sourceRadius = (LENS_SIZE_PX / ZOOM_LEVEL) / 2;
344
 
345
  els.ctx.beginPath();
346
  els.ctx.arc(px, py, sourceRadius, 0, Math.PI * 2);
347
-
348
- // Style: White stroke + Black Shadow for visibility on all backgrounds
349
  els.ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
350
  els.ctx.lineWidth = 1.5;
351
  els.ctx.shadowColor = 'rgba(0,0,0,1)';
352
  els.ctx.shadowBlur = 4;
353
- els.ctx.fillStyle = 'rgba(255, 255, 255, 0.15)'; // Faint transparent fill
354
-
355
  els.ctx.stroke();
356
  els.ctx.fill();
357
- els.ctx.shadowBlur = 0; // Reset
358
  }
359
  }
360
 
@@ -367,18 +699,18 @@
367
 
368
  if (active) {
369
  const lens = els.magnifier;
370
- const w = els.canvas.width; const h = els.canvas.height;
371
- const px = x * w; const py = y * h;
 
 
372
 
373
- // Float above finger
374
- let top = py - LENS_SIZE_PX - 50;
375
- if (top < 0) top = py + 50;
376
 
377
  lens.style.display = 'block';
378
- lens.style.left = (px - LENS_SIZE_PX/2) + 'px';
379
  lens.style.top = top + 'px';
380
 
381
- // Zoom bg
382
  lens.style.backgroundSize = `${w * ZOOM_LEVEL}px ${h * ZOOM_LEVEL}px`;
383
  const bgX = (px * ZOOM_LEVEL) - (LENS_SIZE_PX / 2);
384
  const bgY = (py * ZOOM_LEVEL) - (LENS_SIZE_PX / 2);
@@ -399,57 +731,81 @@
399
  }
400
 
401
  function hitTest(x, y) {
402
- const pad = 30 / els.canvas.width;
403
  for (let i = boxes.length - 1; i >= 0; i--) {
404
  const b = boxes[i];
405
- 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 }; }
406
- const mx=Math.min(b.tl.x,b.br.x), Mx=Math.max(b.tl.x,b.br.x);
407
- const my=Math.min(b.tl.y,b.br.y), My=Math.max(b.tl.y,b.br.y);
408
- if (x > mx && x < Mx && y > my && y < My) return { type: 'body', index: i };
 
 
 
 
 
 
409
  }
410
  return null;
411
  }
412
 
413
  function onDown(e) {
414
- if(e.target.closest('#box-toolbar')) return;
415
  e.preventDefault();
416
- const {x, y} = getPos(e);
417
 
418
- updateMagnifierState(x, y, true); // Show Lens + Source Indicator
419
 
420
  const hit = hitTest(x, y);
421
  if (hit) {
422
- dragTarget = hit; selectedBoxIndex = hit.index; startPositions = JSON.parse(JSON.stringify(boxes[hit.index]));
423
- startX = x; startY = y; updateToolbar(); updateStitchButton();
 
 
 
 
 
424
  } else {
425
- selectedBoxIndex = -1; els.toolbar.style.display = 'none'; isDrawing = true; startX = x; startY = y;
 
 
 
 
426
  }
427
  drawBoxes();
428
  }
429
 
430
  function onMove(e) {
431
- if(isDrawing || dragTarget) {
432
- e.preventDefault();
433
- const {x, y} = getPos(e);
434
- updateMagnifierState(x, y, true); // Update positions
435
-
436
- const dx = x - startX, dy = y - startY;
437
- if (dragTarget) {
438
  const b = boxes[dragTarget.index];
439
- if (dragTarget.type === 'corner') { b[dragTarget.corner].x = x; b[dragTarget.corner].y = y; }
440
- else { ['tl','tr','bl','br'].forEach(k => { b[k].x = startPositions[k].x + dx; b[k].y = startPositions[k].y + dy; }); }
441
- drawBoxes(); updateToolbar();
442
- } else if (isDrawing) {
 
 
 
 
 
 
 
 
443
  drawBoxes();
444
  const sx = startX * els.canvas.width, sy = startY * els.canvas.height;
445
  const w = (x - startX) * els.canvas.width, h = (y - startY) * els.canvas.height;
446
- els.ctx.strokeStyle = 'rgba(255, 77, 77, 0.5)'; els.ctx.strokeRect(sx, sy, w, h);
447
- }
 
448
  }
449
  }
450
 
451
  function onUp(e) {
452
- updateMagnifierState(0, 0, false); // Hide Lens + Source Indicator
453
 
454
  if (isDrawing) {
455
  const rect = els.canvas.getBoundingClientRect();
@@ -469,29 +825,83 @@
469
  selectedBoxIndex = boxes.length - 1;
470
  }
471
  }
472
- isDrawing = false; dragTarget = null;
473
- saveBoxes(); drawBoxes(); updateToolbar(); updateStitchButton();
 
 
 
 
474
  }
475
 
476
  // --- HELPERS ---
477
  function setupListeners() {
478
  els.canvas.addEventListener('mousedown', onDown);
479
- els.canvas.addEventListener('touchstart', onDown, {passive:false});
480
  document.addEventListener('mousemove', onMove);
481
- document.addEventListener('touchmove', onMove, {passive:false});
482
  document.addEventListener('mouseup', onUp);
483
  document.addEventListener('touchend', onUp);
484
 
485
- document.getElementById('backBtn').onclick = () => location.href = CONFIG.imageIndex>0 ? `/cropv2/${CONFIG.sessionId}/${CONFIG.imageIndex-1}` : `/v2`;
486
- document.getElementById('clearBtn').onclick = () => { if(confirm("Clear?")) { boxes=[]; selectedBoxIndex=-1; saveBoxes(); drawBoxes(); els.toolbar.style.display='none'; } };
487
- document.getElementById('delete-btn').onclick = (e) => { e.stopPropagation(); boxes.splice(selectedBoxIndex,1); selectedBoxIndex=-1; els.toolbar.style.display='none'; saveBoxes(); drawBoxes(); };
488
- document.getElementById('move-up-btn').onclick = (e) => { e.stopPropagation(); if(selectedBoxIndex<boxes.length-1) { const b=boxes.splice(selectedBoxIndex,1)[0]; boxes.splice(selectedBoxIndex+1,0,b); selectedBoxIndex++; saveBoxes(); drawBoxes(); updateToolbar(); }};
489
- document.getElementById('move-down-btn').onclick = (e) => { e.stopPropagation(); if(selectedBoxIndex>0) { const b=boxes.splice(selectedBoxIndex,1)[0]; boxes.splice(selectedBoxIndex-1,0,b); selectedBoxIndex--; saveBoxes(); drawBoxes(); updateToolbar(); }};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  document.getElementById('stitch-btn').onclick = handleStitch;
491
  document.getElementById('processBtn').onclick = processPage;
 
492
  // Data Entry
493
  document.getElementById('dataToggle').onclick = () => {
494
  const panel = document.getElementById('dataPanel');
 
 
 
 
 
 
 
495
  if (!panel.classList.contains('show') && selectedBoxIndex === -1 && boxes.length > 0) {
496
  selectedBoxIndex = 0;
497
  updateToolbar();
@@ -521,7 +931,6 @@
521
  if (!e.shiftKey) return;
522
  const key = e.key.toLowerCase();
523
 
524
- // Navigation
525
  if (e.key === 'ArrowRight') {
526
  e.preventDefault();
527
  document.getElementById('processBtn').click();
@@ -533,7 +942,6 @@
533
  return;
534
  }
535
 
536
- // Box Navigation
537
  if (key === 'q') {
538
  e.preventDefault();
539
  if (boxes.length > 0) {
@@ -544,7 +952,6 @@
544
  return;
545
  }
546
 
547
- // Box Actions (Require Selection)
548
  if (selectedBoxIndex === -1) return;
549
 
550
  if (key === 'm') {
@@ -573,10 +980,28 @@
573
 
574
  // Filters
575
  document.getElementById('filterToggle').onclick = () => {
576
- document.getElementById('filtersPanel').classList.toggle('show');
 
 
 
 
 
 
 
 
577
  };
578
 
579
- ['brightness','contrast','gamma'].forEach(id => document.getElementById(id).addEventListener('input', updateFilters));
 
 
 
 
 
 
 
 
 
 
580
  }
581
 
582
  function updateDataPanel() {
@@ -585,13 +1010,13 @@
585
  const form = document.getElementById('data-form');
586
 
587
  if (!b) {
588
- if(msg) msg.style.display = 'block';
589
- if(form) form.style.display = 'none';
590
  return;
591
  }
592
 
593
- if(msg) msg.style.display = 'none';
594
- if(form) form.style.display = 'block';
595
 
596
  document.getElementById('box-q-num').value = b.question_number || '';
597
  document.getElementById('box-status').value = b.status || 'unattempted';
@@ -601,14 +1026,20 @@
601
 
602
  function updateToolbar() {
603
  updateDataPanel();
604
- if (selectedBoxIndex === -1) { els.toolbar.style.display = 'none'; return; }
 
 
 
605
  const b = boxes[selectedBoxIndex];
606
  const p = (pt) => ({ x: pt.x * els.canvas.width, y: pt.y * els.canvas.height });
607
  const maxX = Math.max(p(b.tr).x, p(b.br).x);
608
  const minY = Math.min(p(b.tl).y, p(b.tr).y);
609
- let left = maxX - 180; if(left < 0) left = 0;
 
610
  let top = minY + 10;
611
- els.toolbar.style.left = `${left}px`; els.toolbar.style.top = `${top}px`; els.toolbar.style.display = 'flex';
 
 
612
  }
613
 
614
  function updateStitchButton() {
@@ -616,69 +1047,151 @@
616
  const icon = btn.querySelector('i');
617
  const isBuffer = stitchBuffer && stitchBuffer.session_id === CONFIG.sessionId;
618
  const isStitched = selectedBoxIndex > -1 && boxes[selectedBoxIndex]?.remote_stitch_source;
619
- if (isBuffer) { icon.className='bi bi-link-45deg'; btn.style.color='#0dcaf0'; }
620
- else if (isStitched) { icon.className='bi bi-x-lg'; btn.style.color='#dc3545'; }
621
- else { icon.className='bi bi-scissors'; btn.style.color='#e9ecef'; }
 
 
 
 
 
 
 
622
  }
623
 
624
  function handleStitch(e) {
625
- e.stopPropagation(); if(selectedBoxIndex === -1) return;
 
626
  const b = boxes[selectedBoxIndex];
627
- if(stitchBuffer && stitchBuffer.session_id === CONFIG.sessionId) {
628
- b.remote_stitch_source = { page_index: stitchBuffer.page_index, box: stitchBuffer.box };
629
- stitchBuffer = null; localStorage.removeItem('gemini_stitch_buffer'); toast('Boxes Linked!');
630
- } else if(b.remote_stitch_source) {
631
- b.remote_stitch_source = null; toast('Link Removed');
 
 
 
 
 
 
632
  } else {
633
- const minX=Math.min(b.tl.x,b.bl.x), minY=Math.min(b.tl.y,b.tr.y), maxX=Math.max(b.tr.x,b.br.x), maxY=Math.max(b.bl.y,b.br.y);
 
634
  const cleanBox = { ...b, x: minX, y: minY, w: maxX - minX, h: maxY - minY };
635
- stitchBuffer = { session_id: CONFIG.sessionId, page_index: CONFIG.imageIndex, box: cleanBox };
636
- localStorage.setItem('gemini_stitch_buffer', JSON.stringify(stitchBuffer)); toast('Copied!');
 
 
 
 
 
637
  }
638
- saveBoxes(); updateStitchButton(); drawBoxes();
 
 
639
  }
640
 
641
  function toast(msg) {
642
- const t = document.createElement('div'); t.className = 'toast align-items-center show fade p-2 rounded-3';
 
643
  t.innerHTML = `<div class="d-flex"><div class="toast-body">${msg}</div></div>`;
644
- document.getElementById('toastContainer').appendChild(t); setTimeout(() => t.remove(), 2500);
 
645
  }
646
 
647
  async function processPage() {
648
- if(!boxes.length && !confirm("Skip page?")) return;
649
- document.getElementById('loader-overlay').style.display='flex';
 
 
 
650
  const finalBoxes = boxes.map(b => ({
651
- ...b, x: Math.min(b.tl.x, b.bl.x), y: Math.min(b.tl.y, b.tr.y),
 
 
652
  w: Math.max(b.tr.x, b.br.x) - Math.min(b.tl.x, b.bl.x),
653
  h: Math.max(b.bl.y, b.br.y) - Math.min(b.tl.y, b.tr.y)
654
  }));
655
- const cv = document.createElement('canvas'); cv.width = els.image.naturalWidth; cv.height = els.image.naturalHeight;
656
- const c = cv.getContext('2d'); c.filter = els.image.style.filter; c.drawImage(els.image, 0, 0);
 
 
 
 
 
 
 
 
657
  try {
658
- const res = await fetch('/process_crop_v2', { method: 'POST', headers: {'Content-Type': 'application/json'},
659
- body: JSON.stringify({ session_id: CONFIG.sessionId, image_index: CONFIG.imageIndex, boxes: finalBoxes, imageData: cv.toDataURL('image/jpeg', 0.85) }) });
660
- if(!res.ok) throw new Error(await res.text());
661
- const next = CONFIG.imageIndex + 1;
662
- location.href = next < CONFIG.totalPages ? `/cropv2/${CONFIG.sessionId}/${next}` : `/question_entry_v2/${CONFIG.sessionId}`;
663
- } catch(e) { alert(e.message); document.getElementById('loader-overlay').style.display='none'; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  }
665
 
666
- function saveBoxes() { localStorage.setItem(storageKey, JSON.stringify(boxes)); }
 
 
 
667
  function loadBoxes() {
668
  try {
669
  const s = localStorage.getItem(storageKey);
670
- 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(); }
671
- } catch(e){}
 
 
 
 
 
 
 
 
 
 
672
  }
673
 
674
  function updateFilters() {
675
- const b = document.getElementById('brightness').value, c = document.getElementById('contrast').value, g = document.getElementById('gamma').value;
676
- document.getElementById('val-b').innerText=b; document.getElementById('val-c').innerText=c; document.getElementById('val-g').innerText=g;
677
- els.image.style.filter = `brightness(${100 + parseFloat(b)}%) contrast(${c})`;
 
 
 
 
 
 
678
  els.magnifier.style.filter = els.image.style.filter;
679
- localStorage.setItem('pdfFilters', JSON.stringify({b,c,g}));
 
 
 
 
 
 
 
 
680
  }
681
- 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(); }
682
 
683
  init();
684
  </script>
 
13
  --slider-height: 90px;
14
  --bg-dark: #121212;
15
  --surface-color: #1e1e1e;
16
+ --lens-size: 140px;
17
  }
18
 
19
  html, body {
 
29
  flex-direction: column;
30
  }
31
 
32
+ /* --- PROGRESS BAR --- */
33
+ #progress-bar {
34
+ position: fixed;
35
+ top: 0;
36
+ left: 0;
37
+ right: 0;
38
+ height: 3px;
39
+ background: linear-gradient(90deg, #0d6efd, #0dcaf0);
40
+ transform: scaleX(0);
41
+ transform-origin: left;
42
+ transition: transform 0.3s ease;
43
+ z-index: 10000;
44
+ box-shadow: 0 0 10px rgba(13, 110, 253, 0.5);
45
+ }
46
+ #progress-bar.active {
47
+ animation: progress-pulse 2s ease-in-out infinite;
48
+ }
49
+ @keyframes progress-pulse {
50
+ 0%, 100% { opacity: 1; }
51
+ 50% { opacity: 0.7; }
52
+ }
53
+
54
  /* --- 1. HEADER --- */
55
  .app-header {
56
  height: var(--header-height);
 
110
  overflow: hidden;
111
  }
112
 
 
113
  #magnifier::after {
114
+ content: '';
115
+ position: absolute;
116
+ top: 50%;
117
+ left: 50%;
118
+ width: 12px;
119
+ height: 12px;
120
  border-left: 2px solid rgba(255,50,50,0.8);
121
  border-top: 2px solid rgba(255,50,50,0.8);
122
  transform: translate(-50%, -50%);
 
124
 
125
  /* --- 3. THUMBNAILS --- */
126
  .thumbnail-bar {
127
+ height: var(--slider-height);
128
+ flex-shrink: 0;
129
+ background: #212529;
130
+ border-top: 1px solid #495057;
131
+ display: flex;
132
+ align-items: center;
133
+ padding: 0 10px;
134
+ padding-bottom: env(safe-area-inset-bottom);
135
+ gap: 10px;
136
+ overflow-x: auto;
137
+ z-index: 40;
138
+ }
139
+ .thumb-item {
140
+ height: 60px;
141
+ min-width: 45px;
142
+ border-radius: 6px;
143
+ overflow: hidden;
144
+ position: relative;
145
+ border: 2px solid transparent;
146
+ opacity: 0.6;
147
+ background: #000;
148
+ flex-shrink: 0;
149
+ cursor: pointer;
150
+ transition: all 0.2s ease;
151
+ }
152
+ .thumb-item.active {
153
+ border-color: #0d6efd;
154
+ opacity: 1;
155
+ transform: scale(1.05);
156
+ }
157
+ .thumb-item img {
158
+ width: 100%;
159
+ height: 100%;
160
+ object-fit: cover;
161
+ opacity: 0;
162
+ transition: opacity 0.3s ease-in;
163
  }
 
 
 
164
  .thumb-item img.loaded { opacity: 1; }
165
+ .thumb-item .thumb-loader {
166
+ position: absolute;
167
+ top: 50%;
168
+ left: 50%;
169
+ transform: translate(-50%, -50%);
170
+ width: 20px;
171
+ height: 20px;
172
+ border: 2px solid #495057;
173
+ border-top-color: #0d6efd;
174
+ border-radius: 50%;
175
+ animation: spin 0.8s linear infinite;
176
+ }
177
+ @keyframes spin {
178
+ to { transform: translate(-50%, -50%) rotate(360deg); }
179
+ }
180
+ .thumb-number {
181
+ position: absolute;
182
+ bottom: 0;
183
+ right: 0;
184
+ background: rgba(0,0,0,0.7);
185
+ color: #fff;
186
+ font-size: 10px;
187
+ padding: 2px 4px;
188
+ }
189
 
190
  /* --- FLOATING UI --- */
191
+ #box-toolbar {
192
+ position: absolute;
193
+ background: rgba(33, 37, 41, 0.95);
194
+ border: 1px solid #6c757d;
195
+ border-radius: 50px;
196
+ padding: 8px 16px;
197
+ display: none;
198
+ gap: 16px;
199
+ backdrop-filter: blur(5px);
200
+ z-index: 100;
201
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
202
+ }
203
+ #box-toolbar button {
204
+ background: transparent;
205
+ border: none;
206
+ color: #e9ecef;
207
+ width: 32px;
208
+ height: 32px;
209
+ font-size: 1.4rem;
210
+ padding: 0;
211
+ display: flex;
212
+ align-items: center;
213
+ justify-content: center;
214
+ }
215
+ #box-toolbar button:active {
216
+ transform: scale(0.9);
217
+ color: #fff;
218
+ }
219
  #box-toolbar button.delete-btn { color: #dc3545; }
220
 
221
+ /* --- FAB CONTAINER - Improved Spacing --- */
222
+ .fab-container {
223
+ position: absolute;
224
+ bottom: 20px;
225
+ right: 20px;
226
+ z-index: 45;
227
+ display: flex;
228
+ flex-direction: column;
229
+ gap: 16px;
230
+ }
231
+ .fab-btn {
232
+ width: 56px;
233
+ height: 56px;
234
+ border-radius: 50%;
235
+ background: #212529;
236
+ border: 1px solid #495057;
237
+ color: white;
238
+ display: flex;
239
+ align-items: center;
240
+ justify-content: center;
241
+ font-size: 1.3rem;
242
+ box-shadow: 0 4px 12px rgba(0,0,0,0.4);
243
+ transition: transform 0.2s, background 0.2s;
244
+ cursor: pointer;
245
+ }
246
+ .fab-btn:hover {
247
+ transform: scale(1.05);
248
+ background: #2c3034;
249
+ }
250
+ .fab-btn:active {
251
+ transform: scale(0.95);
252
+ }
253
 
254
+ /* --- PANELS - Better Positioning --- */
255
+ .filters-panel {
256
+ position: absolute;
257
+ right: 0;
258
+ background: #212529;
259
+ border-radius: 12px;
260
+ padding: 16px;
261
+ width: 220px;
262
+ box-shadow: 0 5px 20px rgba(0,0,0,0.6);
263
+ display: none;
264
+ border: 1px solid #495057;
265
+ }
266
  .filters-panel.show { display: block; }
267
+
268
+ /* Data Panel - Higher up to avoid brightness panel */
269
+ #dataPanel {
270
+ bottom: 150px;
271
+ }
272
+
273
+ /* Brightness Panel - Lower */
274
+ #filtersPanel {
275
+ bottom: 80px;
276
+ }
277
+
278
  .form-range { height: 4px; }
279
 
280
+ #loader-overlay {
281
+ position: fixed;
282
+ inset: 0;
283
+ background: rgba(0,0,0,0.7);
284
+ display: none;
285
+ align-items: center;
286
+ justify-content: center;
287
+ z-index: 9999;
288
+ }
289
+ .toast-container {
290
+ position: fixed;
291
+ top: 80px;
292
+ left: 50%;
293
+ transform: translateX(-50%);
294
+ z-index: 2000;
295
+ width: auto;
296
+ pointer-events: none;
297
+ }
298
+ .toast {
299
+ background: #333;
300
+ color: white;
301
+ border: 1px solid #555;
302
+ pointer-events: auto;
303
+ }
304
  </style>
305
  </head>
306
  <body>
307
  {% include '_navbar.html' %}
308
 
309
+ <!-- Progress Bar -->
310
+ <div id="progress-bar"></div>
311
+
312
  <header class="app-header">
313
  <h1 class="header-title"><i class="bi bi-bounding-box me-2"></i>Page {{ image_index + 1 }} / {{ total_pages }}</h1>
314
  <div class="header-actions">
 
333
  </div>
334
  </div>
335
 
336
+ <!-- Floating Actions -->
337
+ <div class="fab-container">
338
+ <!-- Data Panel - Higher up -->
339
+ <div class="filters-panel" id="dataPanel">
340
+ <div id="no-selection-msg" class="text-center text-muted small py-3">Select a box to edit</div>
341
+ <div id="data-form" style="display: none;">
342
+ <div class="mb-2">
343
+ <label class="small text-secondary">Q. No</label>
344
+ <input type="text" class="form-control form-control-sm bg-dark text-white border-secondary" id="box-q-num">
345
+ </div>
346
+ <div class="mb-2">
347
+ <label class="small text-secondary">Status</label>
348
+ <select class="form-select form-select-sm bg-dark text-white border-secondary" id="box-status">
349
+ <option value="unattempted">Unattempted</option>
350
+ <option value="correct">Correct</option>
351
+ <option value="wrong">Wrong</option>
352
+ </select>
353
+ </div>
354
+ <div class="row g-2 mb-0">
355
+ <div class="col-6">
356
+ <label class="small text-secondary">Marked</label>
357
+ <input type="text" class="form-control form-control-sm bg-dark text-white border-secondary" id="box-marked">
358
+ </div>
359
+ <div class="col-6">
360
+ <label class="small text-secondary">Actual</label>
361
+ <input type="text" class="form-control form-control-sm bg-dark text-white border-secondary" id="box-actual">
362
+ </div>
363
+ </div>
364
+ </div>
365
  </div>
366
+ <button class="fab-btn" id="dataToggle" title="Data Entry"><i class="bi bi-pencil-square"></i></button>
 
 
367
 
368
+ <!-- Brightness Panel - Lower -->
369
+ <div class="filters-panel" id="filtersPanel">
370
+ <div class="mb-3">
371
+ <label class="small text-secondary d-flex justify-content-between">
372
+ Brightness <span id="val-b" class="text-white">0</span>
373
+ </label>
374
+ <input type="range" class="form-range" id="brightness" min="-100" max="100" value="0" step="5">
375
+ </div>
376
+ <div class="mb-3">
377
+ <label class="small text-secondary d-flex justify-content-between">
378
+ Contrast <span id="val-c" class="text-white">1.0</span>
379
+ </label>
380
+ <input type="range" class="form-range" id="contrast" min="0.5" max="2.5" step="0.05" value="1.0">
381
+ </div>
382
+ <div class="mb-0">
383
+ <label class="small text-secondary d-flex justify-content-between">
384
+ Gamma <span id="val-g" class="text-white">1.0</span>
385
+ </label>
386
+ <input type="range" class="form-range" id="gamma" min="0.2" max="2.2" step="0.1" value="1.0">
387
+ </div>
388
  </div>
389
+ <button class="fab-btn" id="filterToggle" title="Brightness & Filters"><i class="bi bi-sliders"></i></button>
390
  </div>
391
  </div>
392
 
393
  <div class="thumbnail-bar">
394
  {% for page in all_pages %}
395
+ <div class="thumb-item {% if page.image_index == image_index %}active{% endif %}" data-page-index="{{ page.image_index }}">
396
+ <div class="thumb-loader"></div>
397
  <img data-src="/image/upload/{{ page.filename }}"
398
  alt="Page {{ page.image_index + 1 }}"
399
  data-session="{{ session_id }}"
 
418
  };
419
  const storageKey = CONFIG.userId ? `cropState_${CONFIG.userId}_${CONFIG.sessionId}_${CONFIG.imageIndex}` : `cropState_${CONFIG.sessionId}_${CONFIG.imageIndex}`;
420
 
421
+ // --- PROGRESS BAR UTILITY ---
422
+ const ProgressBar = {
423
+ el: document.getElementById('progress-bar'),
424
+ show(progress = 0) {
425
+ this.el.style.transform = `scaleX(${progress})`;
426
+ this.el.classList.add('active');
427
+ },
428
+ update(progress) {
429
+ this.el.style.transform = `scaleX(${Math.min(1, Math.max(0, progress))})`;
430
+ },
431
+ hide() {
432
+ this.el.classList.remove('active');
433
+ setTimeout(() => this.el.style.transform = 'scaleX(0)', 300);
434
+ }
435
+ };
436
+
437
+ // --- INDEXED DB CACHE WITH LAZY LOADING ---
438
  const ThumbCache = {
439
+ DB_NAME: 'PDF_Crop_Thumbs',
440
+ STORE_NAME: 'images',
441
+ EXPIRY_MS: 2 * 24 * 60 * 60 * 1000,
442
+ observer: null,
443
+
444
  async open() {
445
  return new Promise((resolve, reject) => {
446
  const req = indexedDB.open(this.DB_NAME, 1);
447
+ req.onupgradeneeded = (e) => {
448
+ const db = e.target.result;
449
+ if (!db.objectStoreNames.contains(this.STORE_NAME)) {
450
+ db.createObjectStore(this.STORE_NAME, { keyPath: 'url' });
451
+ }
452
+ };
453
+ req.onsuccess = (e) => resolve(e.target.result);
454
+ req.onerror = (e) => reject(e);
455
  });
456
  },
457
+
458
  async cleanup() {
459
  const db = await this.open();
460
  return new Promise((resolve) => {
461
+ const tx = db.transaction(this.STORE_NAME, 'readwrite');
462
+ const store = tx.objectStore(this.STORE_NAME);
463
+ const now = Date.now();
464
  const req = store.openCursor();
465
+
466
+ req.onsuccess = (e) => {
467
+ const cursor = e.target.result;
468
+ if (cursor) {
469
+ if ((now - cursor.value.timestamp) > this.EXPIRY_MS) {
470
+ cursor.delete();
471
+ }
472
+ cursor.continue();
473
+ } else {
474
+ resolve();
475
+ }
476
+ };
477
  req.onerror = () => resolve();
478
  });
479
  },
480
+
481
+ async loadImage(url, imgElement, loaderElement) {
482
  try {
483
+ const db = await this.open();
484
+ const txRead = db.transaction(this.STORE_NAME, 'readonly');
485
  const req = txRead.objectStore(this.STORE_NAME).get(url);
486
+
487
  req.onsuccess = async (e) => {
488
  const record = e.target.result;
489
+ if (record) {
490
+ imgElement.src = URL.createObjectURL(record.blob);
491
+ imgElement.classList.add('loaded');
492
+ if (loaderElement) loaderElement.remove();
493
+ } else {
494
  try {
495
+ const response = await fetch(url);
496
+ const blob = await response.blob();
497
+ const txWrite = db.transaction(this.STORE_NAME, 'readwrite');
498
+ txWrite.objectStore(this.STORE_NAME).put({
499
+ url: url,
500
+ blob: blob,
501
+ timestamp: Date.now(),
502
+ sessionId: CONFIG.sessionId
503
+ });
504
+ imgElement.src = URL.createObjectURL(blob);
505
+ imgElement.classList.add('loaded');
506
+ if (loaderElement) loaderElement.remove();
507
+ } catch (err) {
508
+ imgElement.src = url;
509
+ imgElement.classList.add('loaded');
510
+ if (loaderElement) loaderElement.remove();
511
+ }
512
  }
513
  };
514
+ } catch (err) {
515
+ imgElement.src = url;
516
+ imgElement.classList.add('loaded');
517
+ if (loaderElement) loaderElement.remove();
518
+ }
519
  },
520
+
521
+ initLazyLoading() {
522
+ this.cleanup();
523
+
524
+ const options = {
525
+ root: document.querySelector('.thumbnail-bar'),
526
+ rootMargin: '50px',
527
+ threshold: 0.01
528
+ };
529
+
530
+ this.observer = new IntersectionObserver((entries) => {
531
+ entries.forEach(entry => {
532
+ if (entry.isIntersecting) {
533
+ const img = entry.target.querySelector('.thumb-img');
534
+ const loader = entry.target.querySelector('.thumb-loader');
535
+ const url = img.getAttribute('data-src');
536
+
537
+ if (url && !img.classList.contains('loaded')) {
538
+ this.loadImage(url, img, loader);
539
+ }
540
+
541
+ this.observer.unobserve(entry.target);
542
+ }
543
+ });
544
+ }, options);
545
+
546
+ // Load active thumbnail immediately
547
+ const activeThumb = document.querySelector('.thumb-item.active');
548
+ if (activeThumb) {
549
+ const img = activeThumb.querySelector('.thumb-img');
550
+ const loader = activeThumb.querySelector('.thumb-loader');
551
+ const url = img.getAttribute('data-src');
552
+ if (url) this.loadImage(url, img, loader);
553
+ }
554
+
555
+ // Observe all other thumbnails
556
+ document.querySelectorAll('.thumb-item:not(.active)').forEach(thumb => {
557
+ this.observer.observe(thumb);
558
+ });
559
  }
560
  };
561
 
562
  // --- APP LOGIC ---
563
  const els = {
564
+ image: document.getElementById('main-image'),
565
+ imagePane: document.getElementById('imagePane'),
566
+ cropArea: document.getElementById('crop-area'),
567
+ canvas: document.getElementById('draw-canvas'),
568
+ ctx: document.getElementById('draw-canvas').getContext('2d'),
569
+ toolbar: document.getElementById('box-toolbar'),
570
  magnifier: document.getElementById('magnifier')
571
  };
572
 
 
574
  let selectedBoxIndex = -1;
575
  let isDrawing = false;
576
  let startX, startY;
577
+ let dragTarget = null;
578
  let startPositions = {};
579
  let stitchBuffer = JSON.parse(localStorage.getItem('gemini_stitch_buffer') || 'null');
580
 
581
  // ZOOM STATE
582
+ const LENS_SIZE_PX = 140;
583
  const ZOOM_LEVEL = 2.5;
584
  let isMagnifying = false;
585
+ let magnifierPos = { x: 0, y: 0 };
586
 
587
  function init() {
588
+ ThumbCache.initLazyLoading();
589
+ if (els.image.complete && els.image.naturalWidth > 0) fitImage();
590
+ else els.image.onload = fitImage;
591
+
592
+ const ro = new ResizeObserver(() => requestAnimationFrame(fitImage));
593
+ ro.observe(els.imagePane);
594
+
595
+ loadSettings();
596
+ loadBoxes();
597
+ setupListeners();
598
+ updateStitchButton();
599
+
600
+ const active = document.querySelector('.thumb-item.active');
601
+ if (active) active.scrollIntoView({ inline: 'center' });
602
  }
603
 
604
  function fitImage() {
605
  if (!els.image.naturalWidth) return;
606
  const rect = els.imagePane.getBoundingClientRect();
607
  const padding = 10;
608
+ const availableW = rect.width - padding;
609
+ const availableH = rect.height - padding;
610
  const scale = Math.min(availableW / els.image.naturalWidth, availableH / els.image.naturalHeight);
611
 
612
+ const finalW = Math.floor(els.image.naturalWidth * scale);
613
+ const finalH = Math.floor(els.image.naturalHeight * scale);
614
+ els.cropArea.style.width = `${finalW}px`;
615
+ els.cropArea.style.height = `${finalH}px`;
616
+ els.image.style.width = `${finalW}px`;
617
+ els.image.style.height = `${finalH}px`;
618
+ els.canvas.width = finalW;
619
+ els.canvas.height = finalH;
620
 
621
  if (CONFIG.enableMagnifier) {
622
  els.magnifier.style.backgroundImage = `url('${els.image.src}')`;
 
630
  function drawBoxes() {
631
  els.ctx.clearRect(0, 0, els.canvas.width, els.canvas.height);
632
 
 
633
  boxes.forEach((box, index) => {
634
  const isSelected = index === selectedBoxIndex;
635
  const isStitched = box.remote_stitch_source != null;
 
640
  els.ctx.fillStyle = isSelected ? 'rgba(255, 77, 77, 0.15)' : (isStitched ? 'rgba(13, 202, 240, 0.2)' : 'rgba(255, 193, 7, 0.1)');
641
 
642
  els.ctx.beginPath();
643
+ els.ctx.moveTo(p(box.tl).x, p(box.tl).y);
644
+ els.ctx.lineTo(p(box.tr).x, p(box.tr).y);
645
+ els.ctx.lineTo(p(box.br).x, p(box.br).y);
646
+ els.ctx.lineTo(p(box.bl).x, p(box.bl).y);
647
+ els.ctx.closePath();
648
+ els.ctx.stroke();
649
+ els.ctx.fill();
650
 
651
  if (isSelected) {
652
  els.ctx.fillStyle = 'white';
653
+ ['tl', 'tr', 'bl', 'br'].forEach(k => {
654
+ els.ctx.beginPath();
655
+ els.ctx.arc(p(box[k]).x, p(box[k]).y, 8, 0, Math.PI * 2);
656
+ els.ctx.fill();
657
+ els.ctx.stroke();
658
+ });
659
  }
660
 
661
+ const cx = (p(box.tl).x + p(box.br).x) / 2;
662
+ const cy = (p(box.tl).y + p(box.br).y) / 2;
663
+ els.ctx.font = "bold 24px system-ui";
664
+ els.ctx.fillStyle = "white";
665
+ els.ctx.shadowColor = "rgba(0,0,0,0.8)";
666
+ els.ctx.shadowBlur = 6;
667
  els.ctx.fillText(index + 1, cx - 6, cy + 8);
668
+ if (isStitched) {
669
+ els.ctx.font = "20px system-ui";
670
+ els.ctx.fillText("🔗", p(box.tr).x - 28, p(box.tr).y + 24);
671
+ }
672
+ els.ctx.shadowBlur = 0;
673
  });
674
 
 
675
  if (isMagnifying && CONFIG.enableMagnifier) {
676
  const px = magnifierPos.x * els.canvas.width;
677
  const py = magnifierPos.y * els.canvas.height;
 
 
 
 
678
  const sourceRadius = (LENS_SIZE_PX / ZOOM_LEVEL) / 2;
679
 
680
  els.ctx.beginPath();
681
  els.ctx.arc(px, py, sourceRadius, 0, Math.PI * 2);
 
 
682
  els.ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
683
  els.ctx.lineWidth = 1.5;
684
  els.ctx.shadowColor = 'rgba(0,0,0,1)';
685
  els.ctx.shadowBlur = 4;
686
+ els.ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
 
687
  els.ctx.stroke();
688
  els.ctx.fill();
689
+ els.ctx.shadowBlur = 0;
690
  }
691
  }
692
 
 
699
 
700
  if (active) {
701
  const lens = els.magnifier;
702
+ const w = els.canvas.width;
703
+ const h = els.canvas.height;
704
+ const px = x * w;
705
+ const py = y * h;
706
 
707
+ let top = py - LENS_SIZE_PX - 50;
708
+ if (top < 0) top = py + 50;
 
709
 
710
  lens.style.display = 'block';
711
+ lens.style.left = (px - LENS_SIZE_PX / 2) + 'px';
712
  lens.style.top = top + 'px';
713
 
 
714
  lens.style.backgroundSize = `${w * ZOOM_LEVEL}px ${h * ZOOM_LEVEL}px`;
715
  const bgX = (px * ZOOM_LEVEL) - (LENS_SIZE_PX / 2);
716
  const bgY = (py * ZOOM_LEVEL) - (LENS_SIZE_PX / 2);
 
731
  }
732
 
733
  function hitTest(x, y) {
734
+ const pad = 30 / els.canvas.width;
735
  for (let i = boxes.length - 1; i >= 0; i--) {
736
  const b = boxes[i];
737
+ for (let k of ['tl', 'tr', 'bl', 'br']) {
738
+ if (Math.hypot(b[k].x - x, b[k].y - y) < pad) {
739
+ return { type: 'corner', index: i, corner: k };
740
+ }
741
+ }
742
+ const mx = Math.min(b.tl.x, b.br.x), Mx = Math.max(b.tl.x, b.br.x);
743
+ const my = Math.min(b.tl.y, b.br.y), My = Math.max(b.tl.y, b.br.y);
744
+ if (x > mx && x < Mx && y > my && y < My) {
745
+ return { type: 'body', index: i };
746
+ }
747
  }
748
  return null;
749
  }
750
 
751
  function onDown(e) {
752
+ if (e.target.closest('#box-toolbar')) return;
753
  e.preventDefault();
754
+ const { x, y } = getPos(e);
755
 
756
+ updateMagnifierState(x, y, true);
757
 
758
  const hit = hitTest(x, y);
759
  if (hit) {
760
+ dragTarget = hit;
761
+ selectedBoxIndex = hit.index;
762
+ startPositions = JSON.parse(JSON.stringify(boxes[hit.index]));
763
+ startX = x;
764
+ startY = y;
765
+ updateToolbar();
766
+ updateStitchButton();
767
  } else {
768
+ selectedBoxIndex = -1;
769
+ els.toolbar.style.display = 'none';
770
+ isDrawing = true;
771
+ startX = x;
772
+ startY = y;
773
  }
774
  drawBoxes();
775
  }
776
 
777
  function onMove(e) {
778
+ if (isDrawing || dragTarget) {
779
+ e.preventDefault();
780
+ const { x, y } = getPos(e);
781
+ updateMagnifierState(x, y, true);
782
+
783
+ const dx = x - startX, dy = y - startY;
784
+ if (dragTarget) {
785
  const b = boxes[dragTarget.index];
786
+ if (dragTarget.type === 'corner') {
787
+ b[dragTarget.corner].x = x;
788
+ b[dragTarget.corner].y = y;
789
+ } else {
790
+ ['tl', 'tr', 'bl', 'br'].forEach(k => {
791
+ b[k].x = startPositions[k].x + dx;
792
+ b[k].y = startPositions[k].y + dy;
793
+ });
794
+ }
795
+ drawBoxes();
796
+ updateToolbar();
797
+ } else if (isDrawing) {
798
  drawBoxes();
799
  const sx = startX * els.canvas.width, sy = startY * els.canvas.height;
800
  const w = (x - startX) * els.canvas.width, h = (y - startY) * els.canvas.height;
801
+ els.ctx.strokeStyle = 'rgba(255, 77, 77, 0.5)';
802
+ els.ctx.strokeRect(sx, sy, w, h);
803
+ }
804
  }
805
  }
806
 
807
  function onUp(e) {
808
+ updateMagnifierState(0, 0, false);
809
 
810
  if (isDrawing) {
811
  const rect = els.canvas.getBoundingClientRect();
 
825
  selectedBoxIndex = boxes.length - 1;
826
  }
827
  }
828
+ isDrawing = false;
829
+ dragTarget = null;
830
+ saveBoxes();
831
+ drawBoxes();
832
+ updateToolbar();
833
+ updateStitchButton();
834
  }
835
 
836
  // --- HELPERS ---
837
  function setupListeners() {
838
  els.canvas.addEventListener('mousedown', onDown);
839
+ els.canvas.addEventListener('touchstart', onDown, { passive: false });
840
  document.addEventListener('mousemove', onMove);
841
+ document.addEventListener('touchmove', onMove, { passive: false });
842
  document.addEventListener('mouseup', onUp);
843
  document.addEventListener('touchend', onUp);
844
 
845
+ document.getElementById('backBtn').onclick = () => {
846
+ location.href = CONFIG.imageIndex > 0 ? `/cropv2/${CONFIG.sessionId}/${CONFIG.imageIndex - 1}` : `/v2`;
847
+ };
848
+
849
+ document.getElementById('clearBtn').onclick = () => {
850
+ if (confirm("Clear all boxes?")) {
851
+ boxes = [];
852
+ selectedBoxIndex = -1;
853
+ saveBoxes();
854
+ drawBoxes();
855
+ els.toolbar.style.display = 'none';
856
+ }
857
+ };
858
+
859
+ document.getElementById('delete-btn').onclick = (e) => {
860
+ e.stopPropagation();
861
+ boxes.splice(selectedBoxIndex, 1);
862
+ selectedBoxIndex = -1;
863
+ els.toolbar.style.display = 'none';
864
+ saveBoxes();
865
+ drawBoxes();
866
+ };
867
+
868
+ document.getElementById('move-up-btn').onclick = (e) => {
869
+ e.stopPropagation();
870
+ if (selectedBoxIndex < boxes.length - 1) {
871
+ const b = boxes.splice(selectedBoxIndex, 1)[0];
872
+ boxes.splice(selectedBoxIndex + 1, 0, b);
873
+ selectedBoxIndex++;
874
+ saveBoxes();
875
+ drawBoxes();
876
+ updateToolbar();
877
+ }
878
+ };
879
+
880
+ document.getElementById('move-down-btn').onclick = (e) => {
881
+ e.stopPropagation();
882
+ if (selectedBoxIndex > 0) {
883
+ const b = boxes.splice(selectedBoxIndex, 1)[0];
884
+ boxes.splice(selectedBoxIndex - 1, 0, b);
885
+ selectedBoxIndex--;
886
+ saveBoxes();
887
+ drawBoxes();
888
+ updateToolbar();
889
+ }
890
+ };
891
+
892
  document.getElementById('stitch-btn').onclick = handleStitch;
893
  document.getElementById('processBtn').onclick = processPage;
894
+
895
  // Data Entry
896
  document.getElementById('dataToggle').onclick = () => {
897
  const panel = document.getElementById('dataPanel');
898
+ const filterPanel = document.getElementById('filtersPanel');
899
+
900
+ // Close filter panel if open
901
+ if (filterPanel.classList.contains('show')) {
902
+ filterPanel.classList.remove('show');
903
+ }
904
+
905
  if (!panel.classList.contains('show') && selectedBoxIndex === -1 && boxes.length > 0) {
906
  selectedBoxIndex = 0;
907
  updateToolbar();
 
931
  if (!e.shiftKey) return;
932
  const key = e.key.toLowerCase();
933
 
 
934
  if (e.key === 'ArrowRight') {
935
  e.preventDefault();
936
  document.getElementById('processBtn').click();
 
942
  return;
943
  }
944
 
 
945
  if (key === 'q') {
946
  e.preventDefault();
947
  if (boxes.length > 0) {
 
952
  return;
953
  }
954
 
 
955
  if (selectedBoxIndex === -1) return;
956
 
957
  if (key === 'm') {
 
980
 
981
  // Filters
982
  document.getElementById('filterToggle').onclick = () => {
983
+ const panel = document.getElementById('filtersPanel');
984
+ const dataPanel = document.getElementById('dataPanel');
985
+
986
+ // Close data panel if open
987
+ if (dataPanel.classList.contains('show')) {
988
+ dataPanel.classList.remove('show');
989
+ }
990
+
991
+ panel.classList.toggle('show');
992
  };
993
 
994
+ ['brightness', 'contrast', 'gamma'].forEach(id => {
995
+ document.getElementById(id).addEventListener('input', updateFilters);
996
+ });
997
+
998
+ // Thumbnail Click Navigation
999
+ document.querySelectorAll('.thumb-item').forEach(thumb => {
1000
+ thumb.addEventListener('click', () => {
1001
+ const pageIndex = parseInt(thumb.getAttribute('data-page-index'));
1002
+ location.href = `/cropv2/${CONFIG.sessionId}/${pageIndex}`;
1003
+ });
1004
+ });
1005
  }
1006
 
1007
  function updateDataPanel() {
 
1010
  const form = document.getElementById('data-form');
1011
 
1012
  if (!b) {
1013
+ if (msg) msg.style.display = 'block';
1014
+ if (form) form.style.display = 'none';
1015
  return;
1016
  }
1017
 
1018
+ if (msg) msg.style.display = 'none';
1019
+ if (form) form.style.display = 'block';
1020
 
1021
  document.getElementById('box-q-num').value = b.question_number || '';
1022
  document.getElementById('box-status').value = b.status || 'unattempted';
 
1026
 
1027
  function updateToolbar() {
1028
  updateDataPanel();
1029
+ if (selectedBoxIndex === -1) {
1030
+ els.toolbar.style.display = 'none';
1031
+ return;
1032
+ }
1033
  const b = boxes[selectedBoxIndex];
1034
  const p = (pt) => ({ x: pt.x * els.canvas.width, y: pt.y * els.canvas.height });
1035
  const maxX = Math.max(p(b.tr).x, p(b.br).x);
1036
  const minY = Math.min(p(b.tl).y, p(b.tr).y);
1037
+ let left = maxX - 180;
1038
+ if (left < 0) left = 0;
1039
  let top = minY + 10;
1040
+ els.toolbar.style.left = `${left}px`;
1041
+ els.toolbar.style.top = `${top}px`;
1042
+ els.toolbar.style.display = 'flex';
1043
  }
1044
 
1045
  function updateStitchButton() {
 
1047
  const icon = btn.querySelector('i');
1048
  const isBuffer = stitchBuffer && stitchBuffer.session_id === CONFIG.sessionId;
1049
  const isStitched = selectedBoxIndex > -1 && boxes[selectedBoxIndex]?.remote_stitch_source;
1050
+ if (isBuffer) {
1051
+ icon.className = 'bi bi-link-45deg';
1052
+ btn.style.color = '#0dcaf0';
1053
+ } else if (isStitched) {
1054
+ icon.className = 'bi bi-x-lg';
1055
+ btn.style.color = '#dc3545';
1056
+ } else {
1057
+ icon.className = 'bi bi-scissors';
1058
+ btn.style.color = '#e9ecef';
1059
+ }
1060
  }
1061
 
1062
  function handleStitch(e) {
1063
+ e.stopPropagation();
1064
+ if (selectedBoxIndex === -1) return;
1065
  const b = boxes[selectedBoxIndex];
1066
+ if (stitchBuffer && stitchBuffer.session_id === CONFIG.sessionId) {
1067
+ b.remote_stitch_source = {
1068
+ page_index: stitchBuffer.page_index,
1069
+ box: stitchBuffer.box
1070
+ };
1071
+ stitchBuffer = null;
1072
+ localStorage.removeItem('gemini_stitch_buffer');
1073
+ toast('Boxes Linked!');
1074
+ } else if (b.remote_stitch_source) {
1075
+ b.remote_stitch_source = null;
1076
+ toast('Link Removed');
1077
  } else {
1078
+ const minX = Math.min(b.tl.x, b.bl.x), minY = Math.min(b.tl.y, b.tr.y);
1079
+ const maxX = Math.max(b.tr.x, b.br.x), maxY = Math.max(b.bl.y, b.br.y);
1080
  const cleanBox = { ...b, x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1081
+ stitchBuffer = {
1082
+ session_id: CONFIG.sessionId,
1083
+ page_index: CONFIG.imageIndex,
1084
+ box: cleanBox
1085
+ };
1086
+ localStorage.setItem('gemini_stitch_buffer', JSON.stringify(stitchBuffer));
1087
+ toast('Copied!');
1088
  }
1089
+ saveBoxes();
1090
+ updateStitchButton();
1091
+ drawBoxes();
1092
  }
1093
 
1094
  function toast(msg) {
1095
+ const t = document.createElement('div');
1096
+ t.className = 'toast align-items-center show fade p-2 rounded-3';
1097
  t.innerHTML = `<div class="d-flex"><div class="toast-body">${msg}</div></div>`;
1098
+ document.getElementById('toastContainer').appendChild(t);
1099
+ setTimeout(() => t.remove(), 2500);
1100
  }
1101
 
1102
  async function processPage() {
1103
+ if (!boxes.length && !confirm("Skip this page?")) return;
1104
+
1105
+ ProgressBar.show(0.3);
1106
+ document.getElementById('loader-overlay').style.display = 'flex';
1107
+
1108
  const finalBoxes = boxes.map(b => ({
1109
+ ...b,
1110
+ x: Math.min(b.tl.x, b.bl.x),
1111
+ y: Math.min(b.tl.y, b.tr.y),
1112
  w: Math.max(b.tr.x, b.br.x) - Math.min(b.tl.x, b.bl.x),
1113
  h: Math.max(b.bl.y, b.br.y) - Math.min(b.tl.y, b.tr.y)
1114
  }));
1115
+
1116
+ const cv = document.createElement('canvas');
1117
+ cv.width = els.image.naturalWidth;
1118
+ cv.height = els.image.naturalHeight;
1119
+ const c = cv.getContext('2d');
1120
+ c.filter = els.image.style.filter;
1121
+ c.drawImage(els.image, 0, 0);
1122
+
1123
+ ProgressBar.update(0.5);
1124
+
1125
  try {
1126
+ const res = await fetch('/process_crop_v2', {
1127
+ method: 'POST',
1128
+ headers: { 'Content-Type': 'application/json' },
1129
+ body: JSON.stringify({
1130
+ session_id: CONFIG.sessionId,
1131
+ image_index: CONFIG.imageIndex,
1132
+ boxes: finalBoxes,
1133
+ imageData: cv.toDataURL('image/jpeg', 0.85)
1134
+ })
1135
+ });
1136
+
1137
+ if (!res.ok) throw new Error(await res.text());
1138
+
1139
+ ProgressBar.update(1);
1140
+ setTimeout(() => {
1141
+ const next = CONFIG.imageIndex + 1;
1142
+ location.href = next < CONFIG.totalPages
1143
+ ? `/cropv2/${CONFIG.sessionId}/${next}`
1144
+ : `/question_entry_v2/${CONFIG.sessionId}`;
1145
+ }, 200);
1146
+ } catch (e) {
1147
+ alert(e.message);
1148
+ document.getElementById('loader-overlay').style.display = 'none';
1149
+ ProgressBar.hide();
1150
+ }
1151
  }
1152
 
1153
+ function saveBoxes() {
1154
+ localStorage.setItem(storageKey, JSON.stringify(boxes));
1155
+ }
1156
+
1157
  function loadBoxes() {
1158
  try {
1159
  const s = localStorage.getItem(storageKey);
1160
+ if (s) {
1161
+ boxes = JSON.parse(s).map(b => b.tl ? b : {
1162
+ id: b.id || Date.now(),
1163
+ tl: { x: b.x, y: b.y },
1164
+ tr: { x: b.x + b.w, y: b.y },
1165
+ bl: { x: b.x, y: b.y + b.h },
1166
+ br: { x: b.x + b.w, y: b.y + b.h },
1167
+ remote_stitch_source: b.remote_stitch_source
1168
+ });
1169
+ drawBoxes();
1170
+ }
1171
+ } catch (e) {}
1172
  }
1173
 
1174
  function updateFilters() {
1175
+ const b = document.getElementById('brightness').value;
1176
+ const c = document.getElementById('contrast').value;
1177
+ const g = document.getElementById('gamma').value;
1178
+
1179
+ document.getElementById('val-b').innerText = b;
1180
+ document.getElementById('val-c').innerText = c;
1181
+ document.getElementById('val-g').innerText = g;
1182
+
1183
+ els.image.style.filter = `brightness(${100 + parseFloat(b)}%) contrast(${c})`;
1184
  els.magnifier.style.filter = els.image.style.filter;
1185
+ localStorage.setItem('pdfFilters', JSON.stringify({ b, c, g }));
1186
+ }
1187
+
1188
+ function loadSettings() {
1189
+ const s = JSON.parse(localStorage.getItem('pdfFilters') || '{}');
1190
+ if (s.b) document.getElementById('brightness').value = s.b;
1191
+ if (s.c) document.getElementById('contrast').value = s.c;
1192
+ if (s.g) document.getElementById('gamma').value = s.g;
1193
+ updateFilters();
1194
  }
 
1195
 
1196
  init();
1197
  </script>