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