Spaces:
Running
Running
| <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; /* Size of the magnifier DOM element */ | |
| } | |
| 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; | |
| } | |
| /* --- 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; | |
| } | |
| /* Crosshair inside magnifier */ | |
| #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; } | |
| .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-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 { position: absolute; bottom: 20px; right: 20px; z-index: 45; } | |
| .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); } | |
| .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; } | |
| .filters-panel.show { display: block; } | |
| .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' %} | |
| <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"> | |
| <div class="filters-panel" id="dataPanel" style="bottom: 130px;"> | |
| <div id="no-selection-msg" class="text-center text-muted small py-3">Select a box to edit</div> | |
| <div id="data-form"> | |
| <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> | |
| <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"><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 %}" onclick="location.href='/cropv2/{{ session_id }}/{{ page.image_index }}'"> | |
| <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}`; | |
| // --- INDEXED DB CACHE --- | |
| const ThumbCache = { | |
| DB_NAME: 'PDF_Crop_Thumbs', STORE_NAME: 'images', EXPIRY_MS: 2 * 24 * 60 * 60 * 1000, | |
| 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) { | |
| 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'); } | |
| 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'); | |
| } catch (err) { imgElement.src = url; imgElement.classList.add('loaded'); } | |
| } | |
| }; | |
| } catch (err) { imgElement.src = url; imgElement.classList.add('loaded'); } | |
| }, | |
| async initAllThumbs() { | |
| await this.cleanup(); const images = document.querySelectorAll('.thumb-img'); | |
| const process = (img) => { const url = img.getAttribute('data-src'); if(url) this.loadImage(url, img); }; | |
| if ('requestIdleCallback' in window) images.forEach(img => requestIdleCallback(() => process(img))); else images.forEach(img => setTimeout(() => process(img), 10)); | |
| } | |
| }; | |
| // --- 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; // Must match CSS | |
| const ZOOM_LEVEL = 2.5; | |
| let isMagnifying = false; | |
| let magnifierPos = { x: 0, y: 0 }; // Normalized 0-1 | |
| function init() { | |
| ThumbCache.initAllThumbs(); | |
| 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); | |
| // 1. Draw Boxes | |
| 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; | |
| }); | |
| // 2. Draw Magnifier Source Region Indicator | |
| if (isMagnifying && CONFIG.enableMagnifier) { | |
| const px = magnifierPos.x * els.canvas.width; | |
| const py = magnifierPos.y * els.canvas.height; | |
| // Calculate radius on canvas that matches the lens view | |
| // Lens is 140px on screen. Zoom is 2.5x. | |
| // So the source circle diameter is 140 / 2.5 = 56px. Radius = 28px. | |
| const sourceRadius = (LENS_SIZE_PX / ZOOM_LEVEL) / 2; | |
| els.ctx.beginPath(); | |
| els.ctx.arc(px, py, sourceRadius, 0, Math.PI * 2); | |
| // Style: White stroke + Black Shadow for visibility on all backgrounds | |
| 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)'; // Faint transparent fill | |
| els.ctx.stroke(); | |
| els.ctx.fill(); | |
| els.ctx.shadowBlur = 0; // Reset | |
| } | |
| } | |
| // --- 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; | |
| // Float above finger | |
| 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'; | |
| // Zoom bg | |
| 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); // Show Lens + Source Indicator | |
| 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); // Update positions | |
| 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); // Hide Lens + Source Indicator | |
| 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?")) { 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'); | |
| 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(); | |
| // Navigation | |
| if (e.key === 'ArrowRight') { | |
| e.preventDefault(); | |
| document.getElementById('processBtn').click(); | |
| return; | |
| } | |
| if (e.key === 'ArrowLeft') { | |
| e.preventDefault(); | |
| document.getElementById('backBtn').click(); | |
| return; | |
| } | |
| // Box Navigation | |
| if (key === 'q') { | |
| e.preventDefault(); | |
| if (boxes.length > 0) { | |
| selectedBoxIndex = (selectedBoxIndex + 1) % boxes.length; | |
| updateToolbar(); | |
| drawBoxes(); | |
| } | |
| return; | |
| } | |
| // Box Actions (Require Selection) | |
| 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 = () => { | |
| document.getElementById('filtersPanel').classList.toggle('show'); | |
| }; | |
| ['brightness','contrast','gamma'].forEach(id => document.getElementById(id).addEventListener('input', updateFilters)); | |
| } | |
| 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), 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 page?")) return; | |
| 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); | |
| 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()); | |
| const next = CONFIG.imageIndex + 1; | |
| location.href = next < CONFIG.totalPages ? `/cropv2/${CONFIG.sessionId}/${next}` : `/question_entry_v2/${CONFIG.sessionId}`; | |
| } catch(e) { alert(e.message); document.getElementById('loader-overlay').style.display='none'; } | |
| } | |
| 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, c = document.getElementById('contrast').value, 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> | |