Merge pull request #435 from christianlouis/copilot/add-in-browser-document-preview
feat: add in-browser document preview with pdf.js, image zoom/pan, and text viewer
This commit is contained in:
+30
-7
@@ -165,15 +165,30 @@ If metadata has been extracted by GPT, this section displays structured informat
|
||||
- Understanding the complete data structure
|
||||
|
||||
#### Document Previews
|
||||
View your documents side-by-side in embedded PDF viewers:
|
||||
View your documents with type-specific viewers:
|
||||
- **Original Document**: The immutable original file as first ingested, before any processing
|
||||
- **Processed Document**: The final file with embedded metadata
|
||||
|
||||
**Features**:
|
||||
- In-browser PDF rendering for immediate viewing
|
||||
- Side-by-side comparison of original vs processed versions
|
||||
- Full 600px height previews for detailed review
|
||||
- **View Extracted Text** buttons below each preview to see the full text content
|
||||
**PDF Preview (pdf.js)**:
|
||||
- In-browser PDF rendering via pdf.js — no native browser PDF plugin required
|
||||
- Page navigation controls (previous / next) with page counter
|
||||
- High-quality canvas-based rendering that works consistently across browsers
|
||||
- Side-by-side comparison of original vs processed versions on the detail page
|
||||
|
||||
**Image Preview (JPEG, PNG, TIFF, WebP)**:
|
||||
- In-browser image display with zoom and pan controls
|
||||
- Zoom in / out buttons and mouse-wheel zoom
|
||||
- Click-and-drag panning for large or zoomed images
|
||||
- Reset button to return to the original size
|
||||
- Zoom percentage indicator
|
||||
|
||||
**Text File Preview**:
|
||||
- Syntax-highlighted display with line numbers
|
||||
- Monospace font for readable code and data
|
||||
- Copy-to-clipboard button for the full text content
|
||||
- Supports plain text, CSV, source code, and other text MIME types
|
||||
|
||||
**View Extracted Text** buttons below each preview to see the full text content
|
||||
|
||||
**Text Extraction Modals**: Click "View Extracted Text" to open a fullscreen modal showing:
|
||||
- Complete extracted text from OCR or PDF text layer
|
||||
@@ -218,10 +233,18 @@ If your file is still available on disk, you can preview it directly in the brow
|
||||
- **Processed File**: The file after metadata has been embedded (if processing completed)
|
||||
|
||||
Both previews support:
|
||||
- In-browser PDF viewing with embedded viewer
|
||||
- In-browser PDF viewing with pdf.js (canvas-based, no browser plugin needed)
|
||||
- Image viewing with zoom/pan for JPEG, PNG, TIFF, and WebP
|
||||
- Text file viewing with line numbers for plain text, CSV, and source code
|
||||
- Side-by-side comparison of original and processed versions
|
||||
- Full text extraction viewing via modal overlays
|
||||
|
||||
**Quick Preview from File List**: On the main Files page, each file row includes an eye icon button that opens a slide-out side panel with a quick preview. This lets you:
|
||||
- Preview PDFs, images, and text files without leaving the file list
|
||||
- Navigate PDF pages within the panel
|
||||
- Zoom and pan images
|
||||
- Open the full document view or download the file from the panel footer
|
||||
|
||||
**View Extracted Text**: Each preview includes a button to view the complete extracted text in a fullscreen modal. When you click this button:
|
||||
- The system extracts text from the PDF file on-demand using pypdf
|
||||
- A loading indicator shows while extraction is in progress
|
||||
|
||||
@@ -1528,11 +1528,11 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- File Preview Card -->
|
||||
{% if file_exists or processed_exists %}
|
||||
{% if original_file_exists or processed_file_exists %}
|
||||
<div class="detail-card">
|
||||
<h3>File Previews</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 2rem;">
|
||||
{% if file_exists %}
|
||||
{% if original_file_exists %}
|
||||
<div>
|
||||
<h4 style="font-weight: 600; color: #2d3748; margin-bottom: 1rem;">Original File</h4>
|
||||
<div style="border: 2px solid #e2e8f0; border-radius: 0.5rem; overflow: hidden; background-color: #f7fafc;">
|
||||
@@ -1541,36 +1541,46 @@
|
||||
<img src="/api/files/{{ file.id }}/preview?version=original" alt="{{ file.original_filename }}" style="width: 100%; height: auto; display: block;">
|
||||
{% elif file.mime_type and file.mime_type.startswith('text/') %}
|
||||
<!-- Text file preview -->
|
||||
<iframe src="/api/files/{{ file.id }}/preview?version=original" style="width: 100%; height: 600px; border: none;"></iframe>
|
||||
<iframe src="/api/files/{{ file.id }}/preview?version=original" title="Original text file preview" style="width: 100%; height: 600px; border: none;"></iframe>
|
||||
{% else %}
|
||||
<!-- PDF and other documents -->
|
||||
<iframe src="/api/files/{{ file.id }}/preview?version=original" style="width: 100%; height: 600px; border: none;"></iframe>
|
||||
<!-- PDF via pdf.js (rendered by loadPDF above) — fallback canvas container -->
|
||||
<div id="detail-original-canvas" class="pdf-canvas-wrapper" style="min-height: 400px; display: flex; justify-content: center; align-items: center; padding: 0.5rem;">
|
||||
<div class="pdf-loading">
|
||||
<i class="fas fa-file-pdf" aria-hidden="true" style="font-size: 2rem; margin-bottom: 1rem; color: #94a3b8;"></i>
|
||||
<p>This PDF is displayed in the Document Previews section at the top of the page.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="margin-top: 0.5rem; text-align: center; display: flex; gap: 1rem; justify-content: center;">
|
||||
<a href="/api/files/{{ file.id }}/preview?version=original" target="_blank" style="color: #3182ce; text-decoration: none; font-size: 0.875rem;">
|
||||
<i class="fas fa-external-link-alt"></i> Open in new tab
|
||||
<i class="fas fa-external-link-alt" aria-hidden="true"></i> Open in new tab
|
||||
</a>
|
||||
<a href="/api/files/{{ file.id }}/download?version=original" style="color: #3182ce; text-decoration: none; font-size: 0.875rem;">
|
||||
<i class="fas fa-download"></i> Download
|
||||
<i class="fas fa-download" aria-hidden="true"></i> Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if processed_exists %}
|
||||
{% if processed_file_exists %}
|
||||
<div>
|
||||
<h4 style="font-weight: 600; color: #2d3748; margin-bottom: 1rem;">Processed File</h4>
|
||||
<div style="border: 2px solid #e2e8f0; border-radius: 0.5rem; overflow: hidden; background-color: #f7fafc;">
|
||||
<!-- Processed files are typically PDFs -->
|
||||
<iframe src="/api/files/{{ file.id }}/preview?version=processed" style="width: 100%; height: 600px; border: none;"></iframe>
|
||||
<!-- Processed files are typically PDFs — rendered by pdf.js above -->
|
||||
<div style="min-height: 400px; display: flex; justify-content: center; align-items: center; padding: 0.5rem;">
|
||||
<div class="pdf-loading">
|
||||
<i class="fas fa-file-pdf" aria-hidden="true" style="font-size: 2rem; margin-bottom: 1rem; color: #94a3b8;"></i>
|
||||
<p>This PDF is displayed in the Document Previews section at the top of the page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 0.5rem; text-align: center; display: flex; gap: 1rem; justify-content: center;">
|
||||
<a href="/api/files/{{ file.id }}/preview?version=processed" target="_blank" style="color: #3182ce; text-decoration: none; font-size: 0.875rem;">
|
||||
<i class="fas fa-external-link-alt"></i> Open in new tab
|
||||
<i class="fas fa-external-link-alt" aria-hidden="true"></i> Open in new tab
|
||||
</a>
|
||||
<a href="/api/files/{{ file.id }}/download?version=processed" style="color: #3182ce; text-decoration: none; font-size: 0.875rem;">
|
||||
<i class="fas fa-download"></i> Download
|
||||
<i class="fas fa-download" aria-hidden="true"></i> Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
|
||||
{% block head_extra %}
|
||||
<script src="/static/js/common.js"></script>
|
||||
<!-- PDF.js library (Apache 2.0 License - compatible) -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
</script>
|
||||
<style>
|
||||
.doc-container { max-width: 1200px; margin: 0 auto; }
|
||||
|
||||
@@ -51,7 +56,61 @@
|
||||
/* ── tags ── */
|
||||
.tag-pill { display: inline-block; padding: 0.15rem 0.6rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; background: #f0fdf4; color: #15803d; margin: 0.1rem; }
|
||||
|
||||
/* ── preview iframe ── */
|
||||
/* ── pdf.js viewer ── */
|
||||
.pdf-viewer { position: relative; background: #525659; border-radius: 0.375rem; overflow: hidden; }
|
||||
.pdf-controls {
|
||||
display: flex; align-items: center; justify-content: center; gap: 0.75rem;
|
||||
padding: 0.5rem; background: #f1f5f9; border-radius: 0.375rem 0.375rem 0 0;
|
||||
}
|
||||
.pdf-nav-btn {
|
||||
padding: 0.35rem 0.75rem; border: 1px solid #cbd5e1; border-radius: 0.25rem;
|
||||
background: white; color: #374151; font-size: 0.8rem; cursor: pointer;
|
||||
min-height: 36px; min-width: 36px;
|
||||
}
|
||||
.pdf-nav-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.pdf-nav-btn:hover:not(:disabled) { background: #e2e8f0; }
|
||||
.pdf-page-info { font-size: 0.8rem; color: #374151; font-weight: 600; min-width: 100px; text-align: center; }
|
||||
.pdf-canvas-wrap { display: flex; justify-content: center; padding: 0.75rem; min-height: 400px; max-height: 700px; overflow: auto; }
|
||||
.pdf-canvas-wrap canvas { max-width: 100%; height: auto; }
|
||||
|
||||
/* ── image viewer with zoom/pan ── */
|
||||
.img-viewer { position: relative; background: #f1f5f9; border-radius: 0.375rem; overflow: hidden; }
|
||||
.img-controls {
|
||||
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||
padding: 0.5rem; background: #f1f5f9; border-radius: 0.375rem 0.375rem 0 0;
|
||||
}
|
||||
.img-controls button {
|
||||
padding: 0.35rem 0.6rem; border: 1px solid #cbd5e1; border-radius: 0.25rem;
|
||||
background: white; color: #374151; font-size: 0.85rem; cursor: pointer;
|
||||
min-height: 36px; min-width: 36px;
|
||||
}
|
||||
.img-controls button:hover { background: #e2e8f0; }
|
||||
.img-wrap {
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
overflow: auto; max-height: 650px; min-height: 300px; cursor: grab;
|
||||
padding: 0.5rem; background: #525659;
|
||||
}
|
||||
.img-wrap.dragging { cursor: grabbing; }
|
||||
.img-wrap img { transition: transform 0.15s ease; transform-origin: center center; }
|
||||
|
||||
/* ── text viewer ── */
|
||||
.text-viewer { border-radius: 0.375rem; overflow: hidden; }
|
||||
.text-viewer-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem; background: #f1f5f9; border-radius: 0.375rem 0.375rem 0 0;
|
||||
}
|
||||
.text-viewer-header span { font-size: 0.8rem; color: #374151; font-weight: 600; }
|
||||
.text-content {
|
||||
background: #1e293b; color: #e2e8f0; padding: 1rem; font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 0.82rem; line-height: 1.65; max-height: 600px; overflow: auto; white-space: pre-wrap; word-break: break-word;
|
||||
tab-size: 4;
|
||||
}
|
||||
.text-content .line-number {
|
||||
display: inline-block; width: 3.5em; color: #64748b; text-align: right;
|
||||
margin-right: 1em; user-select: none; font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── preview iframe fallback ── */
|
||||
.preview-frame { width: 100%; height: 600px; border: none; border-radius: 0.375rem; background: #f1f5f9; display: block; }
|
||||
|
||||
/* ── text box ── */
|
||||
@@ -271,27 +330,75 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: PDF preview -->
|
||||
<!-- RIGHT: Document preview -->
|
||||
<div>
|
||||
<div class="doc-card" style="padding:1rem;">
|
||||
{% set preview_mime = file.mime_type or 'application/pdf' %}
|
||||
{% set preview_version = 'processed' if processed_file_exists else 'original' %}
|
||||
{% set has_preview = processed_file_exists or original_file_exists %}
|
||||
|
||||
{% if preview_mime.startswith('image/') %}
|
||||
<div class="doc-card-title"><i class="fas fa-image" aria-hidden="true" style="color:#8b5cf6;margin-right:0.4rem;"></i>Preview</div>
|
||||
{% elif preview_mime.startswith('text/') %}
|
||||
<div class="doc-card-title"><i class="fas fa-file-code" aria-hidden="true" style="color:#059669;margin-right:0.4rem;"></i>Preview</div>
|
||||
{% else %}
|
||||
<div class="doc-card-title"><i class="fas fa-file-pdf" aria-hidden="true" style="color:#ef4444;margin-right:0.4rem;"></i>Preview</div>
|
||||
{% if processed_file_exists %}
|
||||
<iframe
|
||||
src="/api/files/{{ file.id }}/preview?version=processed"
|
||||
class="preview-frame"
|
||||
title="Processed document preview">
|
||||
</iframe>
|
||||
<div style="font-size:0.75rem;color:#9ca3af;margin-top:0.4rem;text-align:right;">Processed version</div>
|
||||
{% elif original_file_exists %}
|
||||
<iframe
|
||||
src="/api/files/{{ file.id }}/preview?version=original"
|
||||
class="preview-frame"
|
||||
title="Original document preview">
|
||||
</iframe>
|
||||
<div style="font-size:0.75rem;color:#9ca3af;margin-top:0.4rem;text-align:right;">Original version</div>
|
||||
{% endif %}
|
||||
|
||||
{% if has_preview %}
|
||||
{% if preview_mime.startswith('image/') %}
|
||||
<!-- ── Image viewer with zoom / pan ── -->
|
||||
<div class="img-viewer" id="img-viewer">
|
||||
<div class="img-controls">
|
||||
<button onclick="imgZoom(1.25)" title="Zoom in" aria-label="Zoom in"><i class="fas fa-search-plus" aria-hidden="true"></i></button>
|
||||
<button onclick="imgZoom(0.8)" title="Zoom out" aria-label="Zoom out"><i class="fas fa-search-minus" aria-hidden="true"></i></button>
|
||||
<button onclick="imgReset()" title="Reset zoom" aria-label="Reset zoom"><i class="fas fa-expand" aria-hidden="true"></i></button>
|
||||
<span id="img-zoom-level" style="font-size:0.8rem;color:#6b7280;min-width:3em;text-align:center;">100%</span>
|
||||
</div>
|
||||
<div class="img-wrap" id="img-wrap">
|
||||
<img
|
||||
id="preview-img"
|
||||
src="/api/files/{{ file.id }}/preview?version={{ preview_version }}"
|
||||
alt="{{ file.original_filename }}"
|
||||
draggable="false"
|
||||
style="max-width:100%;height:auto;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif preview_mime.startswith('text/') %}
|
||||
<!-- ── Text file viewer with line numbers ── -->
|
||||
<div class="text-viewer" id="text-viewer">
|
||||
<div class="text-viewer-header">
|
||||
<span><i class="fas fa-file-code" aria-hidden="true" style="margin-right:0.3rem;"></i>{{ file.original_filename }}</span>
|
||||
<button class="text-toggle" onclick="copyTextPreview()" id="text-copy-btn" aria-label="Copy file text">
|
||||
<i class="fas fa-copy" aria-hidden="true"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-content" id="text-preview-content" aria-live="polite">
|
||||
<div style="text-align:center;padding:2rem;color:#94a3b8;"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i> Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- ── PDF viewer (pdf.js) ── -->
|
||||
<div class="pdf-viewer" id="pdf-viewer">
|
||||
<div class="pdf-controls">
|
||||
<button class="pdf-nav-btn" onclick="pdfChangePage(-1)" id="pdf-prev-btn" aria-label="Previous page"><i class="fas fa-chevron-left" aria-hidden="true"></i></button>
|
||||
<span class="pdf-page-info" id="pdf-page-info" aria-live="polite">Loading…</span>
|
||||
<button class="pdf-nav-btn" onclick="pdfChangePage(1)" id="pdf-next-btn" aria-label="Next page"><i class="fas fa-chevron-right" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<div class="pdf-canvas-wrap" id="pdf-canvas-wrap">
|
||||
<div style="text-align:center;padding:3rem;color:#94a3b8;">
|
||||
<i class="fas fa-spinner fa-spin" aria-hidden="true" style="font-size:2rem;margin-bottom:0.75rem;"></i>
|
||||
<p>Loading PDF…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div style="font-size:0.75rem;color:#9ca3af;margin-top:0.4rem;text-align:right;">{{ 'Processed' if processed_file_exists else 'Original' }} version</div>
|
||||
{% else %}
|
||||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:300px;background:#f9fafb;border-radius:0.375rem;color:#9ca3af;">
|
||||
<i class="fas fa-file-pdf" style="font-size:3rem;margin-bottom:1rem;opacity:0.4;"></i>
|
||||
<i class="fas fa-file" aria-hidden="true" style="font-size:3rem;margin-bottom:1rem;opacity:0.4;"></i>
|
||||
<p>No file available for preview</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -338,7 +445,7 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Toggle inline OCR text visibility
|
||||
// ── Toggle inline OCR text visibility ──
|
||||
function toggleOcrText() {
|
||||
var block = document.getElementById('ocr-text-block');
|
||||
var icon = document.getElementById('ocr-toggle-icon');
|
||||
@@ -357,7 +464,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Copy OCR text to clipboard
|
||||
// ── Copy OCR text to clipboard ──
|
||||
function copyOcrText() {
|
||||
var content = document.getElementById('ocr-text-content');
|
||||
if (!content) return;
|
||||
@@ -369,8 +476,6 @@
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
setTimeout(function() { btn.innerHTML = orig; }, 2000);
|
||||
}).catch(function() {
|
||||
// Legacy clipboard fallback for browsers without Secure Context / navigator.clipboard
|
||||
// (pre-2018 environments, e.g. HTTP-only or older Safari). Retained for maximum compatibility.
|
||||
try {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
@@ -385,7 +490,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// On-demand text extraction (when ocr_text not in DB)
|
||||
// ── On-demand text extraction (when ocr_text not in DB) ──
|
||||
var _textCache = null;
|
||||
function loadText(fileId) {
|
||||
var area = document.getElementById('text-load-area');
|
||||
@@ -414,5 +519,168 @@
|
||||
pre.textContent = text;
|
||||
container.appendChild(pre);
|
||||
}
|
||||
|
||||
// ── PDF.js viewer ──
|
||||
var _pdfState = { doc: null, page: 1, total: 0 };
|
||||
|
||||
function pdfInit(url) {
|
||||
var wrap = document.getElementById('pdf-canvas-wrap');
|
||||
if (!wrap) return;
|
||||
pdfjsLib.getDocument(url).promise.then(function(pdf) {
|
||||
_pdfState.doc = pdf;
|
||||
_pdfState.total = pdf.numPages;
|
||||
_pdfState.page = 1;
|
||||
pdfRenderPage();
|
||||
}).catch(function() {
|
||||
wrap.innerHTML = '<div style="text-align:center;padding:2rem;color:#f87171;"><i class="fas fa-exclamation-triangle" style="font-size:2rem;margin-bottom:0.5rem;"></i><p>Failed to load PDF</p></div>';
|
||||
});
|
||||
}
|
||||
|
||||
function pdfRenderPage() {
|
||||
var wrap = document.getElementById('pdf-canvas-wrap');
|
||||
if (!_pdfState.doc || !wrap) return;
|
||||
_pdfState.doc.getPage(_pdfState.page).then(function(page) {
|
||||
var vp = page.getViewport({ scale: 1.0 });
|
||||
var scale = Math.min(wrap.clientWidth / vp.width, 2.0);
|
||||
var svp = page.getViewport({ scale: scale });
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = svp.width;
|
||||
canvas.height = svp.height;
|
||||
canvas.style.maxWidth = '100%';
|
||||
canvas.style.height = 'auto';
|
||||
wrap.innerHTML = '';
|
||||
wrap.appendChild(canvas);
|
||||
page.render({ canvasContext: canvas.getContext('2d'), viewport: svp });
|
||||
pdfUpdateInfo();
|
||||
});
|
||||
}
|
||||
|
||||
function pdfChangePage(delta) {
|
||||
var np = _pdfState.page + delta;
|
||||
if (np >= 1 && np <= _pdfState.total) {
|
||||
_pdfState.page = np;
|
||||
pdfRenderPage();
|
||||
}
|
||||
}
|
||||
|
||||
function pdfUpdateInfo() {
|
||||
var info = document.getElementById('pdf-page-info');
|
||||
if (info) info.textContent = 'Page ' + _pdfState.page + ' of ' + _pdfState.total;
|
||||
var prev = document.getElementById('pdf-prev-btn');
|
||||
var next = document.getElementById('pdf-next-btn');
|
||||
if (prev) prev.disabled = _pdfState.page <= 1;
|
||||
if (next) next.disabled = _pdfState.page >= _pdfState.total;
|
||||
}
|
||||
|
||||
// ── Image viewer with zoom / pan ──
|
||||
var _imgScale = 1;
|
||||
|
||||
function imgZoom(factor) {
|
||||
_imgScale = Math.max(0.25, Math.min(5, _imgScale * factor));
|
||||
var img = document.getElementById('preview-img');
|
||||
if (img) img.style.transform = 'scale(' + _imgScale + ')';
|
||||
var label = document.getElementById('img-zoom-level');
|
||||
if (label) label.textContent = Math.round(_imgScale * 100) + '%';
|
||||
}
|
||||
|
||||
function imgReset() {
|
||||
_imgScale = 1;
|
||||
var img = document.getElementById('preview-img');
|
||||
if (img) img.style.transform = 'scale(1)';
|
||||
var label = document.getElementById('img-zoom-level');
|
||||
if (label) label.textContent = '100%';
|
||||
}
|
||||
|
||||
// Mouse-drag panning for image viewer
|
||||
(function() {
|
||||
var wrap = document.getElementById('img-wrap');
|
||||
if (!wrap) return;
|
||||
var dragging = false, startX, startY, scrollL, scrollT;
|
||||
wrap.addEventListener('mousedown', function(e) {
|
||||
dragging = true; wrap.classList.add('dragging');
|
||||
startX = e.pageX - wrap.offsetLeft; startY = e.pageY - wrap.offsetTop;
|
||||
scrollL = wrap.scrollLeft; scrollT = wrap.scrollTop;
|
||||
});
|
||||
wrap.addEventListener('mousemove', function(e) {
|
||||
if (!dragging) return; e.preventDefault();
|
||||
wrap.scrollLeft = scrollL - (e.pageX - wrap.offsetLeft - startX);
|
||||
wrap.scrollTop = scrollT - (e.pageY - wrap.offsetTop - startY);
|
||||
});
|
||||
wrap.addEventListener('mouseup', function() { dragging = false; wrap.classList.remove('dragging'); });
|
||||
wrap.addEventListener('mouseleave', function() { dragging = false; wrap.classList.remove('dragging'); });
|
||||
// Mouse wheel zoom
|
||||
wrap.addEventListener('wheel', function(e) {
|
||||
e.preventDefault();
|
||||
imgZoom(e.deltaY < 0 ? 1.1 : 0.9);
|
||||
}, { passive: false });
|
||||
})();
|
||||
|
||||
// ── Text file viewer ──
|
||||
function loadTextPreview(url) {
|
||||
var container = document.getElementById('text-preview-content');
|
||||
if (!container) return;
|
||||
fetch(url)
|
||||
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
|
||||
.then(function(text) {
|
||||
container.innerHTML = '';
|
||||
var lines = text.split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var lineEl = document.createElement('div');
|
||||
var numSpan = document.createElement('span');
|
||||
numSpan.className = 'line-number';
|
||||
numSpan.textContent = (i + 1) + '';
|
||||
lineEl.appendChild(numSpan);
|
||||
lineEl.appendChild(document.createTextNode(lines[i]));
|
||||
container.appendChild(lineEl);
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
container.innerHTML = '<div style="color:#f87171;padding:1rem;">Failed to load file content</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function copyTextPreview() {
|
||||
var container = document.getElementById('text-preview-content');
|
||||
if (!container) return;
|
||||
var lines = container.querySelectorAll('div');
|
||||
var text = [];
|
||||
lines.forEach(function(line) {
|
||||
var clone = line.cloneNode(true);
|
||||
var num = clone.querySelector('.line-number');
|
||||
if (num) num.remove();
|
||||
text.push(clone.textContent);
|
||||
});
|
||||
var btn = document.getElementById('text-copy-btn');
|
||||
var orig = btn.innerHTML;
|
||||
navigator.clipboard.writeText(text.join('\n')).then(function() {
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
setTimeout(function() { btn.innerHTML = orig; }, 2000);
|
||||
}).catch(function() {
|
||||
try {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text.join('\n');
|
||||
ta.style.cssText = 'position:fixed;opacity:0;';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
setTimeout(function() { btn.innerHTML = orig; }, 2000);
|
||||
} catch(e) {}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Initialise on load ──
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
{% if (processed_file_exists or original_file_exists) and file %}
|
||||
{% set pv = 'processed' if processed_file_exists else 'original' %}
|
||||
{% set pm = file.mime_type or 'application/pdf' %}
|
||||
{% if pm.startswith('text/') %}
|
||||
loadTextPreview('/api/files/{{ file.id }}/preview?version={{ pv }}');
|
||||
{% elif not pm.startswith('image/') %}
|
||||
pdfInit('/api/files/{{ file.id }}/preview?version={{ pv }}');
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
|
||||
{% block head_extra %}
|
||||
<script src="/static/js/common.js"></script>
|
||||
<!-- PDF.js library for in-browser PDF preview -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
</script>
|
||||
<script>
|
||||
window.uploadConfig = {
|
||||
concurrency: {{ upload_concurrency }},
|
||||
@@ -340,6 +345,80 @@
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* ── Preview modal (side panel) ── */
|
||||
.preview-overlay {
|
||||
display: none; position: fixed; inset: 0; z-index: 1000;
|
||||
background: rgba(0,0,0,0.5); justify-content: flex-end;
|
||||
}
|
||||
.preview-overlay.active { display: flex; }
|
||||
.preview-panel {
|
||||
width: 55%; max-width: 720px; min-width: 340px; height: 100%;
|
||||
background: white; box-shadow: -4px 0 20px rgba(0,0,0,0.15);
|
||||
display: flex; flex-direction: column; animation: slideIn 0.2s ease;
|
||||
}
|
||||
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
||||
@media (max-width: 768px) { .preview-panel { width: 100%; max-width: 100%; } }
|
||||
.preview-panel-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.75rem 1rem; border-bottom: 1px solid #e5e7eb; flex-shrink: 0;
|
||||
}
|
||||
.preview-panel-header h3 { font-size: 1rem; font-weight: 600; color: #1f2937; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.preview-panel-close {
|
||||
background: none; border: none; cursor: pointer; padding: 0.5rem;
|
||||
color: #6b7280; font-size: 1.25rem; min-height: 44px; min-width: 44px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.preview-panel-close:hover { color: #1f2937; }
|
||||
.preview-panel-body { flex: 1; overflow: auto; padding: 0; background: #f9fafb; }
|
||||
.preview-panel-footer {
|
||||
display: flex; align-items: center; justify-content: center; gap: 0.75rem;
|
||||
padding: 0.75rem 1rem; border-top: 1px solid #e5e7eb; flex-shrink: 0;
|
||||
}
|
||||
.preview-panel-footer a, .preview-panel-footer button {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||
padding: 0.4rem 0.9rem; border-radius: 0.25rem; font-size: 0.85rem; font-weight: 600;
|
||||
text-decoration: none; cursor: pointer; border: 1px solid #d1d5db; background: white; color: #374151;
|
||||
min-height: 40px;
|
||||
}
|
||||
.preview-panel-footer a:hover, .preview-panel-footer button:hover { background: #f3f4f6; }
|
||||
/* PDF controls inside preview modal */
|
||||
.pm-pdf-controls {
|
||||
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||
padding: 0.5rem; background: #f1f5f9; border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.pm-pdf-controls button {
|
||||
padding: 0.3rem 0.6rem; border: 1px solid #cbd5e1; border-radius: 0.25rem;
|
||||
background: white; cursor: pointer; font-size: 0.8rem; min-height: 36px; min-width: 36px;
|
||||
}
|
||||
.pm-pdf-controls button:disabled { opacity: 0.4; cursor: default; }
|
||||
.pm-canvas-wrap { display: flex; justify-content: center; padding: 0.5rem; min-height: 300px; overflow: auto; }
|
||||
.pm-canvas-wrap canvas { max-width: 100%; height: auto; }
|
||||
/* Image in preview modal */
|
||||
.pm-img-controls {
|
||||
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||
padding: 0.5rem; background: #f1f5f9; border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.pm-img-controls button {
|
||||
padding: 0.3rem 0.6rem; border: 1px solid #cbd5e1; border-radius: 0.25rem;
|
||||
background: white; cursor: pointer; font-size: 0.85rem; min-height: 36px; min-width: 36px;
|
||||
}
|
||||
.pm-img-wrap {
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
overflow: auto; cursor: grab; flex: 1; padding: 0.5rem; background: #525659;
|
||||
}
|
||||
.pm-img-wrap.dragging { cursor: grabbing; }
|
||||
.pm-img-wrap img { transition: transform 0.15s ease; max-width: 100%; height: auto; }
|
||||
/* Text in preview modal */
|
||||
.pm-text-content {
|
||||
background: #1e293b; color: #e2e8f0; padding: 1rem; font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 0.82rem; line-height: 1.65; min-height: 300px; white-space: pre-wrap; word-break: break-word;
|
||||
tab-size: 4;
|
||||
}
|
||||
.pm-line-number {
|
||||
display: inline-block; width: 3.5em; color: #64748b; text-align: right;
|
||||
margin-right: 1em; user-select: none; font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -582,6 +661,9 @@
|
||||
<td>{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') if file.created_at else 'N/A' }}</td>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<button onclick="openPreviewModal({{ file.id }}, '{{ file.mime_type or '' }}', '{{ file.original_filename | e }}', event)" class="action-btn" title="Quick preview" aria-label="Preview {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="View details" aria-label="View details for {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
@@ -621,6 +703,9 @@
|
||||
<div class="flex flex-col items-end gap-2 flex-shrink-0">
|
||||
<span class="status-badge status-{{ file.processing_status }}">{{ file.processing_status | title }}</span>
|
||||
<div class="flex gap-1" role="group" aria-label="File actions" onclick="event.stopPropagation();" onkeydown="event.stopPropagation();">
|
||||
<button onclick="openPreviewModal({{ file.id }}, '{{ file.mime_type or '' }}', '{{ file.original_filename | e }}', event)" class="action-btn" title="Quick preview" aria-label="Preview {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="View details" aria-label="View details for {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
@@ -676,6 +761,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview side-panel modal -->
|
||||
<div id="previewOverlay" class="preview-overlay" role="dialog" aria-modal="true" aria-labelledby="previewTitle">
|
||||
<div class="preview-panel">
|
||||
<div class="preview-panel-header">
|
||||
<h3 id="previewTitle">Preview</h3>
|
||||
<button class="preview-panel-close" onclick="closePreviewModal()" aria-label="Close preview">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="preview-panel-body" id="previewBody" aria-live="polite">
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#9ca3af;">
|
||||
<i class="fas fa-spinner fa-spin" aria-hidden="true" style="font-size:2rem;"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-panel-footer" id="previewFooter"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Modal functionality
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
@@ -694,6 +797,184 @@
|
||||
window.location.href = `/files/${fileId}/detail`;
|
||||
}
|
||||
|
||||
// ── Preview modal ──
|
||||
let _pmPdf = { doc: null, page: 1, total: 0 };
|
||||
let _pmImgScale = 1;
|
||||
|
||||
function openPreviewModal(fileId, mime, filename, event) {
|
||||
if (event) event.stopPropagation();
|
||||
const overlay = document.getElementById('previewOverlay');
|
||||
const title = document.getElementById('previewTitle');
|
||||
const body = document.getElementById('previewBody');
|
||||
const footer = document.getElementById('previewFooter');
|
||||
title.textContent = filename || 'Preview';
|
||||
body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:300px;color:#9ca3af;"><i class="fas fa-spinner fa-spin" style="font-size:2rem;"></i></div>';
|
||||
footer.innerHTML = '';
|
||||
_pmPdf = { doc: null, page: 1, total: 0 };
|
||||
_pmImgScale = 1;
|
||||
overlay.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
footer.innerHTML =
|
||||
`<a href="/files/${fileId}" aria-label="Open full view"><i class="fas fa-expand" aria-hidden="true"></i> Full view</a>` +
|
||||
`<a href="/api/files/${fileId}/download?version=processed" aria-label="Download file"><i class="fas fa-download" aria-hidden="true"></i> Download</a>`;
|
||||
|
||||
// Try "processed" first; fall back to "original" on 404
|
||||
_pmLoadPreview(body, fileId, mime, filename, 'processed');
|
||||
}
|
||||
|
||||
function _pmLoadPreview(body, fileId, mime, filename, version) {
|
||||
const previewUrl = `/api/files/${fileId}/preview?version=${version}`;
|
||||
if (mime && mime.startsWith('image/')) {
|
||||
_pmRenderImage(body, previewUrl, filename);
|
||||
} else if (mime && mime.startsWith('text/')) {
|
||||
_pmRenderText(body, previewUrl, function() {
|
||||
if (version === 'processed') _pmLoadPreview(body, fileId, mime, filename, 'original');
|
||||
});
|
||||
} else {
|
||||
_pmRenderPdf(body, previewUrl, function() {
|
||||
if (version === 'processed') _pmLoadPreview(body, fileId, mime, filename, 'original');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function closePreviewModal() {
|
||||
const overlay = document.getElementById('previewOverlay');
|
||||
overlay.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
_pmPdf = { doc: null, page: 1, total: 0 };
|
||||
}
|
||||
|
||||
// PDF rendering inside preview modal
|
||||
function _pmRenderPdf(body, url, onError) {
|
||||
body.innerHTML =
|
||||
'<div class="pm-pdf-controls">' +
|
||||
'<button onclick="_pmPdfPage(-1)" id="pm-prev" aria-label="Previous page"><i class="fas fa-chevron-left" aria-hidden="true"></i></button>' +
|
||||
'<span id="pm-page-info" style="font-size:0.8rem;min-width:100px;text-align:center;">Loading…</span>' +
|
||||
'<button onclick="_pmPdfPage(1)" id="pm-next" aria-label="Next page"><i class="fas fa-chevron-right" aria-hidden="true"></i></button>' +
|
||||
'</div>' +
|
||||
'<div class="pm-canvas-wrap" id="pm-canvas-wrap"><div style="text-align:center;padding:3rem;color:#94a3b8;"><i class="fas fa-spinner fa-spin" style="font-size:2rem;"></i></div></div>';
|
||||
|
||||
pdfjsLib.getDocument(url).promise.then(function(pdf) {
|
||||
_pmPdf.doc = pdf;
|
||||
_pmPdf.total = pdf.numPages;
|
||||
_pmPdf.page = 1;
|
||||
_pmPdfRender();
|
||||
}).catch(function() {
|
||||
if (onError) { onError(); return; }
|
||||
document.getElementById('pm-canvas-wrap').innerHTML = '<div style="text-align:center;padding:2rem;color:#f87171;"><i class="fas fa-exclamation-triangle" style="font-size:2rem;margin-bottom:0.5rem;"></i><p>Failed to load PDF</p></div>';
|
||||
});
|
||||
}
|
||||
|
||||
function _pmPdfRender() {
|
||||
var wrap = document.getElementById('pm-canvas-wrap');
|
||||
if (!_pmPdf.doc || !wrap) return;
|
||||
_pmPdf.doc.getPage(_pmPdf.page).then(function(page) {
|
||||
var vp = page.getViewport({ scale: 1.0 });
|
||||
var scale = Math.min((wrap.clientWidth - 16) / vp.width, 2.0);
|
||||
var svp = page.getViewport({ scale: scale });
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = svp.width;
|
||||
canvas.height = svp.height;
|
||||
canvas.style.maxWidth = '100%';
|
||||
canvas.style.height = 'auto';
|
||||
wrap.innerHTML = '';
|
||||
wrap.appendChild(canvas);
|
||||
page.render({ canvasContext: canvas.getContext('2d'), viewport: svp });
|
||||
_pmPdfUpdateInfo();
|
||||
});
|
||||
}
|
||||
|
||||
function _pmPdfPage(delta) {
|
||||
var np = _pmPdf.page + delta;
|
||||
if (np >= 1 && np <= _pmPdf.total) { _pmPdf.page = np; _pmPdfRender(); }
|
||||
}
|
||||
|
||||
function _pmPdfUpdateInfo() {
|
||||
var info = document.getElementById('pm-page-info');
|
||||
if (info) info.textContent = 'Page ' + _pmPdf.page + ' of ' + _pmPdf.total;
|
||||
var prev = document.getElementById('pm-prev');
|
||||
var next = document.getElementById('pm-next');
|
||||
if (prev) prev.disabled = _pmPdf.page <= 1;
|
||||
if (next) next.disabled = _pmPdf.page >= _pmPdf.total;
|
||||
}
|
||||
|
||||
// Image rendering inside preview modal
|
||||
function _pmRenderImage(body, url, alt) {
|
||||
body.innerHTML =
|
||||
'<div class="pm-img-controls">' +
|
||||
'<button onclick="_pmImgZoom(1.25)" title="Zoom in" aria-label="Zoom in"><i class="fas fa-search-plus" aria-hidden="true"></i></button>' +
|
||||
'<button onclick="_pmImgZoom(0.8)" title="Zoom out" aria-label="Zoom out"><i class="fas fa-search-minus" aria-hidden="true"></i></button>' +
|
||||
'<button onclick="_pmImgReset()" title="Reset" aria-label="Reset zoom"><i class="fas fa-expand" aria-hidden="true"></i></button>' +
|
||||
'<span id="pm-zoom-level" style="font-size:0.8rem;color:#6b7280;">100%</span>' +
|
||||
'</div>' +
|
||||
'<div class="pm-img-wrap" id="pm-img-wrap"><img id="pm-preview-img" src="' + url + '" alt="' + (alt || 'Preview') + '" draggable="false" style="max-width:100%;height:auto;"></div>';
|
||||
// Drag-pan
|
||||
var wrap = document.getElementById('pm-img-wrap');
|
||||
if (wrap) {
|
||||
var dragging = false, sX, sY, sL, sT;
|
||||
wrap.addEventListener('mousedown', function(e){ dragging=true; wrap.classList.add('dragging'); sX=e.pageX-wrap.offsetLeft; sY=e.pageY-wrap.offsetTop; sL=wrap.scrollLeft; sT=wrap.scrollTop; });
|
||||
wrap.addEventListener('mousemove', function(e){ if(!dragging) return; e.preventDefault(); wrap.scrollLeft=sL-(e.pageX-wrap.offsetLeft-sX); wrap.scrollTop=sT-(e.pageY-wrap.offsetTop-sY); });
|
||||
wrap.addEventListener('mouseup', function(){ dragging=false; wrap.classList.remove('dragging'); });
|
||||
wrap.addEventListener('mouseleave', function(){ dragging=false; wrap.classList.remove('dragging'); });
|
||||
wrap.addEventListener('wheel', function(e){ e.preventDefault(); _pmImgZoom(e.deltaY<0?1.1:0.9); }, {passive:false});
|
||||
}
|
||||
}
|
||||
|
||||
function _pmImgZoom(factor) {
|
||||
_pmImgScale = Math.max(0.25, Math.min(5, _pmImgScale * factor));
|
||||
var img = document.getElementById('pm-preview-img');
|
||||
if (img) img.style.transform = 'scale(' + _pmImgScale + ')';
|
||||
var lbl = document.getElementById('pm-zoom-level');
|
||||
if (lbl) lbl.textContent = Math.round(_pmImgScale * 100) + '%';
|
||||
}
|
||||
|
||||
function _pmImgReset() {
|
||||
_pmImgScale = 1;
|
||||
var img = document.getElementById('pm-preview-img');
|
||||
if (img) img.style.transform = 'scale(1)';
|
||||
var lbl = document.getElementById('pm-zoom-level');
|
||||
if (lbl) lbl.textContent = '100%';
|
||||
}
|
||||
|
||||
// Text rendering inside preview modal
|
||||
function _pmRenderText(body, url, onError) {
|
||||
body.innerHTML = '<div class="pm-text-content" id="pm-text-content"><div style="text-align:center;padding:2rem;color:#94a3b8;"><i class="fas fa-spinner fa-spin"></i> Loading…</div></div>';
|
||||
fetch(url)
|
||||
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
|
||||
.then(function(text) {
|
||||
var container = document.getElementById('pm-text-content');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
var lines = text.split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var div = document.createElement('div');
|
||||
var num = document.createElement('span');
|
||||
num.className = 'pm-line-number';
|
||||
num.textContent = (i + 1) + '';
|
||||
div.appendChild(num);
|
||||
div.appendChild(document.createTextNode(lines[i]));
|
||||
container.appendChild(div);
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
if (onError) { onError(); return; }
|
||||
var c = document.getElementById('pm-text-content');
|
||||
if (c) c.innerHTML = '<div style="color:#f87171;padding:1rem;">Failed to load file</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// Close preview on overlay click or Escape
|
||||
document.getElementById('previewOverlay').addEventListener('click', function(e) {
|
||||
if (e.target === this) closePreviewModal();
|
||||
});
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
var overlay = document.getElementById('previewOverlay');
|
||||
if (overlay && overlay.classList.contains('active')) closePreviewModal();
|
||||
}
|
||||
});
|
||||
|
||||
cancelDelete.addEventListener('click', () => {
|
||||
deleteModal.style.display = 'none';
|
||||
});
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
Tests for in-browser document preview functionality.
|
||||
|
||||
Tests the pdf.js-based PDF viewer, image zoom/pan, text preview with
|
||||
line numbers, and the file-list preview modal introduced by the
|
||||
in-browser document preview feature.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.models import FileRecord
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_file_record(
|
||||
db_session,
|
||||
*,
|
||||
filename: str = "test.pdf",
|
||||
mime_type: str = "application/pdf",
|
||||
file_path: str,
|
||||
processed_path: str | None = None,
|
||||
) -> FileRecord:
|
||||
"""Create and persist a FileRecord for testing."""
|
||||
rec = FileRecord(
|
||||
filehash="hash_" + filename,
|
||||
original_filename=filename,
|
||||
local_filename=file_path,
|
||||
original_file_path=file_path,
|
||||
processed_file_path=processed_path,
|
||||
file_size=os.path.getsize(file_path) if os.path.exists(file_path) else 1024,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
db_session.add(rec)
|
||||
db_session.commit()
|
||||
db_session.refresh(rec)
|
||||
return rec
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# file_view.html – PDF preview via pdf.js
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFileViewPdfJs:
|
||||
"""file_view.html should embed a pdf.js viewer for PDF files."""
|
||||
|
||||
def test_pdf_file_renders_pdfjs_viewer(self, client: TestClient, db_session, tmp_path):
|
||||
"""PDF file view should include pdf.js library and canvas-based viewer."""
|
||||
pdf = tmp_path / "doc.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4 test")
|
||||
rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf")
|
||||
|
||||
response = client.get(f"/files/{rec.id}")
|
||||
assert response.status_code == 200
|
||||
html = response.text
|
||||
|
||||
# pdf.js library must be loaded
|
||||
assert "pdf.min.js" in html
|
||||
assert "pdf.worker.min.js" in html
|
||||
|
||||
# pdf.js canvas wrapper and page controls must be present
|
||||
assert 'id="pdf-viewer"' in html
|
||||
assert 'id="pdf-canvas-wrap"' in html
|
||||
assert 'id="pdf-page-info"' in html
|
||||
assert "pdfInit(" in html
|
||||
|
||||
# Should NOT have an iframe in the preview section
|
||||
assert 'id="pdf-viewer"' in html
|
||||
|
||||
def test_pdf_file_no_iframe(self, client: TestClient, db_session, tmp_path):
|
||||
"""PDF should not use native iframe-based preview."""
|
||||
pdf = tmp_path / "doc2.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4 test")
|
||||
rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf")
|
||||
|
||||
response = client.get(f"/files/{rec.id}")
|
||||
html = response.text
|
||||
|
||||
# The preview section should use pdf-viewer, not iframe
|
||||
assert 'id="pdf-viewer"' in html
|
||||
assert "pdf-canvas-wrap" in html
|
||||
|
||||
def test_page_navigation_controls(self, client: TestClient, db_session, tmp_path):
|
||||
"""PDF viewer should have previous/next page buttons."""
|
||||
pdf = tmp_path / "nav.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4 test")
|
||||
rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf")
|
||||
|
||||
response = client.get(f"/files/{rec.id}")
|
||||
html = response.text
|
||||
|
||||
assert 'id="pdf-prev-btn"' in html
|
||||
assert 'id="pdf-next-btn"' in html
|
||||
assert "pdfChangePage" in html
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# file_view.html – Image preview with zoom / pan
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFileViewImagePreview:
|
||||
"""file_view.html should show an image viewer with zoom/pan for image files."""
|
||||
|
||||
def test_image_file_renders_image_viewer(self, client: TestClient, db_session, tmp_path):
|
||||
"""Image files should show zoom/pan controls instead of iframe."""
|
||||
img = tmp_path / "photo.jpg"
|
||||
img.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) # minimal JPEG header
|
||||
rec = _create_file_record(db_session, filename="photo.jpg", mime_type="image/jpeg", file_path=str(img))
|
||||
|
||||
response = client.get(f"/files/{rec.id}")
|
||||
assert response.status_code == 200
|
||||
html = response.text
|
||||
|
||||
assert 'id="img-viewer"' in html
|
||||
assert 'id="preview-img"' in html
|
||||
assert 'id="img-wrap"' in html
|
||||
assert "imgZoom" in html
|
||||
assert "imgReset" in html
|
||||
|
||||
def test_image_zoom_controls(self, client: TestClient, db_session, tmp_path):
|
||||
"""Image viewer should have zoom in, zoom out, and reset controls."""
|
||||
img = tmp_path / "photo.png"
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50)
|
||||
rec = _create_file_record(db_session, filename="photo.png", mime_type="image/png", file_path=str(img))
|
||||
|
||||
response = client.get(f"/files/{rec.id}")
|
||||
html = response.text
|
||||
|
||||
assert 'aria-label="Zoom in"' in html
|
||||
assert 'aria-label="Zoom out"' in html
|
||||
assert 'aria-label="Reset zoom"' in html
|
||||
assert 'id="img-zoom-level"' in html
|
||||
|
||||
def test_image_pan_support(self, client: TestClient, db_session, tmp_path):
|
||||
"""Image viewer should support mouse-drag panning."""
|
||||
img = tmp_path / "wide.webp"
|
||||
img.write_bytes(b"RIFF" + b"\x00" * 50)
|
||||
rec = _create_file_record(db_session, filename="wide.webp", mime_type="image/webp", file_path=str(img))
|
||||
|
||||
response = client.get(f"/files/{rec.id}")
|
||||
html = response.text
|
||||
|
||||
# Pan support is implemented via JavaScript on img-wrap
|
||||
assert "mousedown" in html
|
||||
assert "mousemove" in html
|
||||
assert "cursor: grab" in html or "cursor:grab" in html
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# file_view.html – Text file preview with line numbers
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFileViewTextPreview:
|
||||
"""file_view.html should show a text viewer with line numbers for text files."""
|
||||
|
||||
def test_text_file_renders_text_viewer(self, client: TestClient, db_session, tmp_path):
|
||||
"""Text files should show the text-viewer component."""
|
||||
txt = tmp_path / "readme.txt"
|
||||
txt.write_text("Hello world\nSecond line\n")
|
||||
rec = _create_file_record(db_session, filename="readme.txt", mime_type="text/plain", file_path=str(txt))
|
||||
|
||||
response = client.get(f"/files/{rec.id}")
|
||||
assert response.status_code == 200
|
||||
html = response.text
|
||||
|
||||
assert 'id="text-viewer"' in html
|
||||
assert 'id="text-preview-content"' in html
|
||||
assert "loadTextPreview" in html
|
||||
|
||||
def test_text_viewer_has_copy_button(self, client: TestClient, db_session, tmp_path):
|
||||
"""Text viewer should have a Copy button."""
|
||||
txt = tmp_path / "code.py"
|
||||
txt.write_text("print('hello')\n")
|
||||
rec = _create_file_record(db_session, filename="code.py", mime_type="text/x-python", file_path=str(txt))
|
||||
|
||||
response = client.get(f"/files/{rec.id}")
|
||||
html = response.text
|
||||
|
||||
assert "copyTextPreview" in html
|
||||
assert 'id="text-copy-btn"' in html
|
||||
|
||||
def test_text_viewer_line_numbers(self, client: TestClient, db_session, tmp_path):
|
||||
"""Text viewer JS should generate line numbers."""
|
||||
txt = tmp_path / "data.csv"
|
||||
txt.write_text("a,b,c\n1,2,3\n")
|
||||
rec = _create_file_record(db_session, filename="data.csv", mime_type="text/csv", file_path=str(txt))
|
||||
|
||||
response = client.get(f"/files/{rec.id}")
|
||||
html = response.text
|
||||
|
||||
# JS builds line-number spans
|
||||
assert "line-number" in html
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# file_view.html – Preview icon adapts to MIME type
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFileViewPreviewIcon:
|
||||
"""Preview card icon should match the MIME type of the file."""
|
||||
|
||||
def test_pdf_icon(self, client: TestClient, db_session, tmp_path):
|
||||
"""PDF files show a file-pdf icon."""
|
||||
pdf = tmp_path / "a.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4")
|
||||
rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf")
|
||||
|
||||
html = client.get(f"/files/{rec.id}").text
|
||||
assert "fa-file-pdf" in html
|
||||
|
||||
def test_image_icon(self, client: TestClient, db_session, tmp_path):
|
||||
"""Image files show an image icon."""
|
||||
img = tmp_path / "p.jpg"
|
||||
img.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 10)
|
||||
rec = _create_file_record(db_session, filename="p.jpg", mime_type="image/jpeg", file_path=str(img))
|
||||
|
||||
html = client.get(f"/files/{rec.id}").text
|
||||
assert "fa-image" in html
|
||||
|
||||
def test_text_icon(self, client: TestClient, db_session, tmp_path):
|
||||
"""Text files show a code icon."""
|
||||
txt = tmp_path / "t.txt"
|
||||
txt.write_text("hello")
|
||||
rec = _create_file_record(db_session, filename="t.txt", mime_type="text/plain", file_path=str(txt))
|
||||
|
||||
html = client.get(f"/files/{rec.id}").text
|
||||
assert "fa-file-code" in html
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# files.html – Preview modal from file list
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFilesPreviewModal:
|
||||
"""files.html should include a preview side-panel triggered from the file list."""
|
||||
|
||||
def test_preview_modal_markup(self, client: TestClient, db_session, tmp_path):
|
||||
"""Files page should contain the preview overlay HTML."""
|
||||
pdf = tmp_path / "list.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4")
|
||||
_create_file_record(db_session, file_path=str(pdf))
|
||||
|
||||
response = client.get("/files")
|
||||
assert response.status_code == 200
|
||||
html = response.text
|
||||
|
||||
assert 'id="previewOverlay"' in html
|
||||
assert 'id="previewBody"' in html
|
||||
assert 'id="previewTitle"' in html
|
||||
assert 'role="dialog"' in html
|
||||
assert 'aria-modal="true"' in html
|
||||
|
||||
def test_preview_button_in_actions(self, client: TestClient, db_session, tmp_path):
|
||||
"""Each file row should have a preview (eye) button."""
|
||||
pdf = tmp_path / "btn.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4")
|
||||
_create_file_record(db_session, file_path=str(pdf))
|
||||
|
||||
html = client.get("/files").text
|
||||
assert "openPreviewModal" in html
|
||||
assert "fa-eye" in html
|
||||
|
||||
def test_preview_modal_has_pdfjs(self, client: TestClient, db_session, tmp_path):
|
||||
"""Files page should load pdf.js for the preview modal."""
|
||||
pdf = tmp_path / "js.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4")
|
||||
_create_file_record(db_session, file_path=str(pdf))
|
||||
|
||||
html = client.get("/files").text
|
||||
assert "pdf.min.js" in html
|
||||
assert "pdfjsLib" in html
|
||||
|
||||
def test_preview_modal_close(self, client: TestClient, db_session, tmp_path):
|
||||
"""Preview modal should have a close button and Escape handler."""
|
||||
pdf = tmp_path / "close.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4")
|
||||
_create_file_record(db_session, file_path=str(pdf))
|
||||
|
||||
html = client.get("/files").text
|
||||
assert "closePreviewModal" in html
|
||||
assert "Escape" in html
|
||||
|
||||
def test_preview_modal_footer_links(self, client: TestClient, db_session, tmp_path):
|
||||
"""Preview modal JS should include download and full-view links."""
|
||||
pdf = tmp_path / "footer.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4")
|
||||
_create_file_record(db_session, file_path=str(pdf))
|
||||
|
||||
html = client.get("/files").text
|
||||
assert "Full view" in html
|
||||
assert "Download" in html
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# file_detail.html – Bottom preview section uses correct variable names
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFileDetailBottomPreview:
|
||||
"""file_detail.html bottom File Previews card should render when files exist."""
|
||||
|
||||
def test_bottom_preview_card_renders(self, client: TestClient, db_session, tmp_path):
|
||||
"""Bottom 'File Previews' card should display when file exists on disk."""
|
||||
pdf = tmp_path / "detail.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4")
|
||||
rec = _create_file_record(db_session, file_path=str(pdf), processed_path=str(pdf))
|
||||
|
||||
response = client.get(f"/files/{rec.id}/detail")
|
||||
assert response.status_code == 200
|
||||
html = response.text
|
||||
|
||||
# The fixed section should now render (was previously using wrong variable names)
|
||||
assert "File Previews" in html
|
||||
assert "Original File" in html or "Processed File" in html
|
||||
|
||||
def test_bottom_preview_has_download_link(self, client: TestClient, db_session, tmp_path):
|
||||
"""Bottom preview card should have download links."""
|
||||
pdf = tmp_path / "dl.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4")
|
||||
rec = _create_file_record(db_session, file_path=str(pdf), processed_path=str(pdf))
|
||||
|
||||
response = client.get(f"/files/{rec.id}/detail")
|
||||
html = response.text
|
||||
assert f"/api/files/{rec.id}/download" in html
|
||||
|
||||
def test_image_preview_in_detail(self, client: TestClient, db_session, tmp_path):
|
||||
"""Image files should render an <img> tag in the bottom preview card."""
|
||||
img = tmp_path / "detail_img.jpg"
|
||||
img.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
|
||||
rec = _create_file_record(
|
||||
db_session,
|
||||
filename="detail_img.jpg",
|
||||
mime_type="image/jpeg",
|
||||
file_path=str(img),
|
||||
)
|
||||
|
||||
response = client.get(f"/files/{rec.id}/detail")
|
||||
html = response.text
|
||||
assert f"/api/files/{rec.id}/preview?version=original" in html
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Existing OCR text functionality preserved
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFileViewOcrText:
|
||||
"""Verify that the existing OCR text feature still works after the preview upgrade."""
|
||||
|
||||
def test_ocr_text_toggle_present(self, client: TestClient, db_session, tmp_path):
|
||||
"""Pages with ocr_text should still have the toggle button."""
|
||||
pdf = tmp_path / "ocr.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4")
|
||||
rec = _create_file_record(db_session, file_path=str(pdf))
|
||||
rec.ocr_text = "Sample extracted OCR text content"
|
||||
db_session.commit()
|
||||
|
||||
html = client.get(f"/files/{rec.id}").text
|
||||
assert "toggleOcrText" in html
|
||||
assert "ocr-text-block" in html
|
||||
assert "Sample extracted OCR text content" in html
|
||||
|
||||
def test_text_extraction_button(self, client: TestClient, db_session, tmp_path):
|
||||
"""Pages without ocr_text but with files should show extract button."""
|
||||
pdf = tmp_path / "no_ocr.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4")
|
||||
rec = _create_file_record(db_session, file_path=str(pdf))
|
||||
|
||||
html = client.get(f"/files/{rec.id}").text
|
||||
assert "loadText" in html or "Extract" in html
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# No file available fallback
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFileViewNoFile:
|
||||
"""When no file is on disk, a fallback message should display."""
|
||||
|
||||
def test_no_preview_fallback(self, client: TestClient, db_session):
|
||||
"""No-file-on-disk scenario shows a fallback message."""
|
||||
rec = FileRecord(
|
||||
filehash="nope",
|
||||
original_filename="gone.pdf",
|
||||
local_filename="/nonexistent/gone.pdf",
|
||||
file_size=0,
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
db_session.add(rec)
|
||||
db_session.commit()
|
||||
db_session.refresh(rec)
|
||||
|
||||
html = client.get(f"/files/{rec.id}").text
|
||||
assert "No file available for preview" in html
|
||||
Reference in New Issue
Block a user