Spaces:
Sleeping
Sleeping
| <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 Page {{ image_index + 1 }}</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; | |
| } | |
| 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; | |
| } | |
| /* --- PROGRESS BAR --- */ | |
| #progress-bar { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| height: 3px; | |
| background: linear-gradient(90deg, #0d6efd, #0dcaf0); | |
| transform: scaleX(0); | |
| transform-origin: left; | |
| transition: transform 0.3s ease; | |
| z-index: 10000; | |
| box-shadow: 0 0 10px rgba(13, 110, 253, 0.5); | |
| } | |
| #progress-bar.active { | |
| animation: progress-pulse 2s ease-in-out infinite; | |
| } | |
| @keyframes progress-pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.7; } | |
| } | |
| /* --- 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: #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; } | |
| /* --- 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%; } | |
| #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; | |
| } | |
| #main-image { display: block; pointer-events: none; } | |
| #draw-canvas { | |
| position: absolute; | |
| top: 0; left: 0; | |
| width: 100%; height: 100%; | |
| z-index: 10; | |
| cursor: crosshair; | |
| touch-action: none; | |
| } | |
| /* --- MAGNIFIER LENS --- */ | |
| #magnifier { | |
| 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; | |
| } | |
| #magnifier::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: #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; | |
| } | |
| .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; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| } | |
| .thumb-item.active { | |
| border-color: #0d6efd; | |
| opacity: 1; | |
| transform: scale(1.05); | |
| } | |
| .thumb-item img { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| opacity: 0; | |
| transition: opacity 0.3s ease-in; | |
| } | |
| .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: #0d6efd; | |
| 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.7); | |
| color: #fff; | |
| font-size: 10px; | |
| padding: 2px 4px; | |
| } | |
| /* --- 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(5px); | |
| z-index: 100; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.3); | |
| } | |
| #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; | |
| } | |
| #box-toolbar button:active { | |
| transform: scale(0.9); | |
| color: #fff; | |
| } | |
| #box-toolbar button.delete-btn { color: #dc3545; } | |
| /* --- 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: #212529; | |
| border: 1px solid #495057; | |
| color: white; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 1.3rem; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.4); | |
| transition: transform 0.2s, background 0.2s; | |
| cursor: pointer; | |
| } | |
| .fab-btn:hover { | |
| transform: scale(1.05); | |
| background: #2c3034; | |
| } | |
| .fab-btn:active { | |
| transform: scale(0.95); | |
| } | |
| /* --- PANELS - Better Positioning --- */ | |
| .filters-panel { | |
| position: absolute; | |
| 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; | |
| } | |
| .filters-panel.show { display: block; } | |
| /* Data Panel - Higher up to avoid brightness panel */ | |
| #dataPanel { | |
| bottom: 150px; | |
| } | |
| /* Brightness Panel - Lower */ | |
| #filtersPanel { | |
| bottom: 80px; | |
| } | |
| .form-range { height: 4px; } | |
| #loader-overlay { | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(0,0,0,0.7); | |
| display: none; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 9999; | |
| } | |
| .toast-container { | |
| position: fixed; | |
| top: 80px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| z-index: 2000; | |
| width: auto; | |
| pointer-events: none; | |
| } | |
| .toast { | |
| background: #333; | |
| color: white; | |
| border: 1px solid #555; | |
| pointer-events: auto; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| {% include '_navbar.html' %} | |
| <!-- Progress Bar --> | |
| <div id="progress-bar"></div> | |
| <header class="app-header"> | |
| <h1 class="header-title"><i class="bi bi-bounding-box me-2"></i>Page {{ image_index + 1 }} / {{ total_pages }}</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" id="imagePane"> | |
| <div id="crop-area"> | |
| <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> | |
| <!-- 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"> | |
| {% 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 %} | |
| </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' }} | |
| }; | |
| const storageKey = CONFIG.userId ? `cropState_${CONFIG.userId}_${CONFIG.sessionId}_${CONFIG.imageIndex}` : `cropState_${CONFIG.sessionId}_${CONFIG.imageIndex}`; | |
| // --- PROGRESS BAR UTILITY --- | |
| const ProgressBar = { | |
| el: document.getElementById('progress-bar'), | |
| show(progress = 0) { | |
| this.el.style.transform = `scaleX(${progress})`; | |
| this.el.classList.add('active'); | |
| }, | |
| update(progress) { | |
| this.el.style.transform = `scaleX(${Math.min(1, Math.max(0, progress))})`; | |
| }, | |
| hide() { | |
| this.el.classList.remove('active'); | |
| setTimeout(() => this.el.style.transform = 'scaleX(0)', 300); | |
| } | |
| }; | |
| // --- 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, | |
| 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 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) { | |
| try { | |
| const db = await this.open(); | |
| const txRead = db.transaction(this.STORE_NAME, 'readonly'); | |
| const req = txRead.objectStore(this.STORE_NAME).get(url); | |
| req.onsuccess = async (e) => { | |
| const record = e.target.result; | |
| if (record) { | |
| imgElement.src = URL.createObjectURL(record.blob); | |
| imgElement.classList.add('loaded'); | |
| if (loaderElement) loaderElement.remove(); | |
| } else { | |
| try { | |
| const response = await fetch(url); | |
| const blob = await response.blob(); | |
| const txWrite = db.transaction(this.STORE_NAME, 'readwrite'); | |
| txWrite.objectStore(this.STORE_NAME).put({ | |
| url: url, | |
| blob: blob, | |
| timestamp: Date.now(), | |
| sessionId: CONFIG.sessionId | |
| }); | |
| imgElement.src = URL.createObjectURL(blob); | |
| imgElement.classList.add('loaded'); | |
| if (loaderElement) loaderElement.remove(); | |
| } catch (err) { | |
| imgElement.src = url; | |
| imgElement.classList.add('loaded'); | |
| if (loaderElement) loaderElement.remove(); | |
| } | |
| } | |
| }; | |
| } catch (err) { | |
| imgElement.src = url; | |
| imgElement.classList.add('loaded'); | |
| if (loaderElement) loaderElement.remove(); | |
| } | |
| }, | |
| 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 | |
| document.querySelectorAll('.thumb-item:not(.active)').forEach(thumb => { | |
| this.observer.observe(thumb); | |
| }); | |
| } | |
| }; | |
| // --- 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') | |
| }; | |
| let boxes = []; | |
| let selectedBoxIndex = -1; | |
| 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() { | |
| ThumbCache.initLazyLoading(); | |
| if (els.image.complete && els.image.naturalWidth > 0) fitImage(); | |
| else els.image.onload = fitImage; | |
| const ro = new ResizeObserver(() => requestAnimationFrame(fitImage)); | |
| ro.observe(els.imagePane); | |
| loadSettings(); | |
| loadBoxes(); | |
| setupListeners(); | |
| updateStitchButton(); | |
| const active = document.querySelector('.thumb-item.active'); | |
| if (active) active.scrollIntoView({ inline: 'center' }); | |
| } | |
| function fitImage() { | |
| if (!els.image.naturalWidth) return; | |
| const rect = els.imagePane.getBoundingClientRect(); | |
| const padding = 10; | |
| const availableW = rect.width - padding; | |
| const availableH = rect.height - padding; | |
| 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(); | |
| drawBoxes(); | |
| } | |
| // --- 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; | |
| } | |
| } | |
| // --- 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) { | |
| const rect = els.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 hitTest(x, y) { | |
| const pad = 30 / els.canvas.width; | |
| for (let i = boxes.length - 1; i >= 0; i--) { | |
| const b = boxes[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')) return; | |
| e.preventDefault(); | |
| const { x, y } = getPos(e); | |
| updateMagnifierState(x, y, true); | |
| const hit = hitTest(x, y); | |
| if (hit) { | |
| dragTarget = hit; | |
| selectedBoxIndex = hit.index; | |
| startPositions = JSON.parse(JSON.stringify(boxes[hit.index])); | |
| startX = x; | |
| startY = y; | |
| updateToolbar(); | |
| updateStitchButton(); | |
| } else { | |
| selectedBoxIndex = -1; | |
| els.toolbar.style.display = 'none'; | |
| isDrawing = true; | |
| startX = x; | |
| startY = y; | |
| } | |
| drawBoxes(); | |
| } | |
| function onMove(e) { | |
| if (isDrawing || dragTarget) { | |
| e.preventDefault(); | |
| const { x, y } = getPos(e); | |
| updateMagnifierState(x, y, true); | |
| const dx = x - startX, dy = y - startY; | |
| if (dragTarget) { | |
| const b = boxes[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(); | |
| updateToolbar(); | |
| } else if (isDrawing) { | |
| drawBoxes(); | |
| const sx = startX * els.canvas.width, sy = startY * els.canvas.height; | |
| const w = (x - startX) * els.canvas.width, h = (y - startY) * els.canvas.height; | |
| els.ctx.strokeStyle = 'rgba(255, 77, 77, 0.5)'; | |
| els.ctx.strokeRect(sx, sy, w, h); | |
| } | |
| } | |
| } | |
| function onUp(e) { | |
| updateMagnifierState(0, 0, false); | |
| if (isDrawing) { | |
| const rect = els.canvas.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) * els.canvas.width > 20) { | |
| boxes.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 | |
| }); | |
| selectedBoxIndex = boxes.length - 1; | |
| } | |
| } | |
| isDrawing = false; | |
| dragTarget = null; | |
| saveBoxes(); | |
| drawBoxes(); | |
| updateToolbar(); | |
| updateStitchButton(); | |
| } | |
| // --- HELPERS --- | |
| function setupListeners() { | |
| els.canvas.addEventListener('mousedown', onDown); | |
| els.canvas.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 = () => { | |
| location.href = CONFIG.imageIndex > 0 ? `/cropv2/${CONFIG.sessionId}/${CONFIG.imageIndex - 1}` : `/v2`; | |
| }; | |
| document.getElementById('clearBtn').onclick = () => { | |
| if (confirm("Clear all boxes?")) { | |
| boxes = []; | |
| selectedBoxIndex = -1; | |
| saveBoxes(); | |
| drawBoxes(); | |
| els.toolbar.style.display = 'none'; | |
| } | |
| }; | |
| document.getElementById('delete-btn').onclick = (e) => { | |
| e.stopPropagation(); | |
| boxes.splice(selectedBoxIndex, 1); | |
| selectedBoxIndex = -1; | |
| els.toolbar.style.display = 'none'; | |
| saveBoxes(); | |
| drawBoxes(); | |
| }; | |
| 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(); | |
| } | |
| }; | |
| 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(); | |
| } | |
| }; | |
| document.getElementById('stitch-btn').onclick = handleStitch; | |
| document.getElementById('processBtn').onclick = processPage; | |
| // 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 (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); | |
| }); | |
| // Thumbnail Click Navigation | |
| document.querySelectorAll('.thumb-item').forEach(thumb => { | |
| thumb.addEventListener('click', () => { | |
| const pageIndex = parseInt(thumb.getAttribute('data-page-index')); | |
| location.href = `/cropv2/${CONFIG.sessionId}/${pageIndex}`; | |
| }); | |
| }); | |
| } | |
| function updateDataPanel() { | |
| const b = selectedBoxIndex > -1 ? boxes[selectedBoxIndex] : null; | |
| 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(); | |
| if (selectedBoxIndex === -1) { | |
| els.toolbar.style.display = 'none'; | |
| return; | |
| } | |
| 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'; | |
| } | |
| function updateStitchButton() { | |
| const btn = document.getElementById('stitch-btn'); | |
| const icon = btn.querySelector('i'); | |
| const isBuffer = stitchBuffer && stitchBuffer.session_id === CONFIG.sessionId; | |
| const isStitched = selectedBoxIndex > -1 && 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'; | |
| } | |
| } | |
| function handleStitch(e) { | |
| e.stopPropagation(); | |
| if (selectedBoxIndex === -1) return; | |
| const b = boxes[selectedBoxIndex]; | |
| 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: CONFIG.imageIndex, | |
| box: cleanBox | |
| }; | |
| localStorage.setItem('gemini_stitch_buffer', JSON.stringify(stitchBuffer)); | |
| toast('Copied!'); | |
| } | |
| saveBoxes(); | |
| updateStitchButton(); | |
| drawBoxes(); | |
| } | |
| 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 processPage() { | |
| if (!boxes.length && !confirm("Skip this page?")) return; | |
| ProgressBar.show(0.3); | |
| document.getElementById('loader-overlay').style.display = 'flex'; | |
| 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); | |
| ProgressBar.update(0.5); | |
| try { | |
| const res = await fetch('/process_crop_v2', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| session_id: CONFIG.sessionId, | |
| image_index: CONFIG.imageIndex, | |
| boxes: finalBoxes, | |
| imageData: cv.toDataURL('image/jpeg', 0.85) | |
| }) | |
| }); | |
| if (!res.ok) throw new Error(await res.text()); | |
| ProgressBar.update(1); | |
| setTimeout(() => { | |
| const next = CONFIG.imageIndex + 1; | |
| location.href = next < CONFIG.totalPages | |
| ? `/cropv2/${CONFIG.sessionId}/${next}` | |
| : `/question_entry_v2/${CONFIG.sessionId}`; | |
| }, 200); | |
| } catch (e) { | |
| alert(e.message); | |
| document.getElementById('loader-overlay').style.display = 'none'; | |
| ProgressBar.hide(); | |
| } | |
| } | |
| function saveBoxes() { | |
| localStorage.setItem(storageKey, JSON.stringify(boxes)); | |
| } | |
| 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 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; | |
| els.image.style.filter = `brightness(${100 + parseFloat(b)}%) contrast(${c})`; | |
| els.magnifier.style.filter = els.image.style.filter; | |
| 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> | |