39301-vm/viewer.php
2026-03-25 16:47:00 +00:00

539 lines
21 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/layout.php';
library_bootstrap();
$documentId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
// Context handling (admin/public)
$context = ($_GET['context'] ?? '') === 'admin' ? 'admin' : 'public';
$publicOnly = $context !== 'admin';
$document = $documentId > 0 ? library_fetch_document($documentId, $publicOnly) : null;
if (!$document || empty($document['file_path'])) {
http_response_code(404);
die('Document not found or no file attached.');
}
$fileUrl = library_file_url((string) $document['file_path']);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= h($document['title_en'] ?: 'Document Viewer') ?> - Reader</title>
<!-- Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<!-- Bootstrap -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
margin: 0;
padding: 0;
background-color: #2d3035;
height: 100vh;
width: 100vw;
overflow: hidden;
display: flex;
flex-direction: column;
color: #fff;
}
#viewer-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
padding: 20px;
}
#flipbook-loader {
text-align: center;
}
#flipbook {
display: none;
}
.page {
background-color: #fff;
box-shadow: inset -1px 0 2px rgba(0,0,0,0.1); /* subtle spine shadow */
}
#toolbar {
height: 60px;
background-color: #1a1d21;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
box-shadow: 0 -2px 10px rgba(0,0,0,0.2);
z-index: 1000;
gap: 1rem;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 10px;
}
.toolbar-btn {
background: none;
border: none;
color: #adb5bd;
font-size: 1.2rem;
cursor: pointer;
padding: 5px 10px;
border-radius: 4px;
transition: color 0.2s, background-color 0.2s;
}
.toolbar-btn:hover {
color: #fff;
background-color: rgba(255,255,255,0.1);
}
.page-indicator {
font-family: monospace;
font-size: 1.1rem;
color: #fff;
white-space: nowrap;
}
.doc-title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
/* Search Styles */
.search-container {
position: relative;
display: flex;
align-items: center;
background: #2d3035;
border-radius: 4px;
padding: 2px;
border: 1px solid #495057;
}
#searchInput {
background: transparent;
border: none;
color: #fff;
padding: 5px 10px;
outline: none;
width: 150px;
font-size: 0.9rem;
}
#searchInput::placeholder {
color: #adb5bd;
}
.search-controls {
display: flex;
border-left: 1px solid #495057;
}
.search-btn {
background: none;
border: none;
color: #adb5bd;
padding: 5px 8px;
cursor: pointer;
font-size: 0.8rem;
}
.search-btn:hover {
color: #fff;
background-color: rgba(255,255,255,0.1);
}
#search-status {
font-size: 0.8rem;
color: #adb5bd;
margin-left: 8px;
white-space: nowrap;
}
</style>
</head>
<body>
<div id="toolbar">
<div class="toolbar-group">
<a href="/document.php?id=<?= $documentId ?><?= $context === 'admin' ? '&context=admin' : '' ?>" class="toolbar-btn" title="Back to Details">
<i class="bi bi-arrow-left"></i>
</a>
<div class="doc-title d-none d-lg-block"><?= h($document['title_en'] ?: 'Document') ?></div>
</div>
<!-- Center: Search -->
<div class="toolbar-group d-none d-md-flex">
<div class="search-container">
<input type="text" id="searchInput" placeholder="Search..." autocomplete="off">
<div class="search-controls">
<button id="btn-search-prev" class="search-btn" title="Previous Match"><i class="bi bi-chevron-up"></i></button>
<button id="btn-search-next" class="search-btn" title="Next Match"><i class="bi bi-chevron-down"></i></button>
</div>
</div>
<span id="search-status"></span>
</div>
<div class="toolbar-group">
<button id="btn-prev" class="toolbar-btn" title="Previous Page">
<i class="bi bi-caret-left-fill"></i>
</button>
<div class="page-indicator">
<span id="page-current">1</span> <span class="text-secondary">/</span> <span id="page-total">--</span>
</div>
<button id="btn-next" class="toolbar-btn" title="Next Page">
<i class="bi bi-caret-right-fill"></i>
</button>
</div>
<div class="toolbar-group">
<a href="<?= h($fileUrl) ?>" download class="toolbar-btn" title="Download PDF">
<i class="bi bi-download"></i>
</a>
</div>
</div>
<div id="viewer-container">
<div id="flipbook-loader">
<div class="spinner-border text-light mb-3" role="status"></div>
<div>Loading Document...</div>
<div id="loader-status" class="text-secondary small mt-2"></div>
</div>
<div id="flipbook"></div>
</div>
<!-- Scripts -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="https://unpkg.com/page-flip/dist/js/page-flip.browser.js"></script>
<script>
// Global State
let pdfDoc = null;
let pageFlip = null;
let searchResults = []; // Array of { pageIndex, rects: [x,y,w,h] }
let currentMatchIndex = -1;
let pageHighlighters = {}; // Map of pageIndex -> CanvasContext (for highlights)
let pageViewports = {}; // Map of pageIndex -> viewport (for rect calc)
document.addEventListener('DOMContentLoaded', async () => {
const pdfUrl = '<?= h($fileUrl) ?>';
const container = document.getElementById('flipbook');
const loader = document.getElementById('flipbook-loader');
const loaderStatus = document.getElementById('loader-status');
const currentSpan = document.getElementById('page-current');
const totalSpan = document.getElementById('page-total');
const viewerContainer = document.getElementById('viewer-container');
// Set worker
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
try {
const loadingTask = pdfjsLib.getDocument(pdfUrl);
loadingTask.onProgress = (p) => {
if (p.total > 0) {
const percent = Math.round((p.loaded / p.total) * 100);
loaderStatus.textContent = `Downloading... ${percent}%`;
}
};
pdfDoc = await loadingTask.promise;
const totalPages = pdfDoc.numPages;
totalSpan.textContent = totalPages;
loaderStatus.textContent = 'Rendering pages...';
// --- Layout Logic ---
const availWidth = viewerContainer.clientWidth - 40;
const availHeight = viewerContainer.clientHeight - 40;
const firstPage = await pdfDoc.getPage(1);
const viewport = firstPage.getViewport({ scale: 1 });
const aspectRatio = viewport.width / viewport.height;
let bookHeight = availHeight;
let bookWidth = bookHeight * aspectRatio;
if (bookWidth * 2 > availWidth) {
bookWidth = availWidth / 2;
bookHeight = bookWidth / aspectRatio;
}
const renderScale = (bookHeight / viewport.height) * 1.5;
// --- Page Generation ---
const canvasPromises = [];
for (let i = 1; i <= totalPages; i++) {
const pageDiv = document.createElement('div');
pageDiv.className = 'page';
// Structure:
// .page-content (relative)
// -> .pdf-canvas (absolute, z=1)
// -> .highlight-canvas (absolute, z=2, pointer-events: none)
const contentDiv = document.createElement('div');
contentDiv.style.position = 'relative';
contentDiv.style.width = '100%';
contentDiv.style.height = '100%';
contentDiv.style.overflow = 'hidden';
const pdfCanvas = document.createElement('canvas');
pdfCanvas.style.position = 'absolute';
pdfCanvas.style.top = '0';
pdfCanvas.style.left = '0';
pdfCanvas.style.width = '100%';
pdfCanvas.style.height = '100%';
const hlCanvas = document.createElement('canvas');
hlCanvas.style.position = 'absolute';
hlCanvas.style.top = '0';
hlCanvas.style.left = '0';
hlCanvas.style.width = '100%';
hlCanvas.style.height = '100%';
hlCanvas.style.pointerEvents = 'none'; // Click through to page
contentDiv.appendChild(pdfCanvas);
contentDiv.appendChild(hlCanvas);
pageDiv.appendChild(contentDiv);
container.appendChild(pageDiv);
// Render Async
canvasPromises.push(async () => {
const page = await pdfDoc.getPage(i);
const vp = page.getViewport({ scale: renderScale });
// Setup PDF Canvas
pdfCanvas.height = vp.height;
pdfCanvas.width = vp.width;
const ctx = pdfCanvas.getContext('2d');
// Setup Highlight Canvas (match dims)
hlCanvas.height = vp.height;
hlCanvas.width = vp.width;
const hlCtx = hlCanvas.getContext('2d');
// Store refs for search
pageHighlighters[i] = hlCtx;
pageViewports[i] = vp;
await page.render({ canvasContext: ctx, viewport: vp }).promise;
});
}
await Promise.all(canvasPromises.map(fn => fn()));
// --- Init Flipbook ---
loader.style.display = 'none';
container.style.display = 'block';
pageFlip = new St.PageFlip(container, {
width: bookWidth,
height: bookHeight,
size: 'fixed',
showCover: true,
maxShadowOpacity: 0.5
});
pageFlip.loadFromHTML(document.querySelectorAll('.page'));
// --- Events ---
pageFlip.on('flip', (e) => {
document.getElementById('page-current').textContent = (pageFlip.getCurrentPageIndex() + 1);
});
document.getElementById('btn-prev').addEventListener('click', () => pageFlip.flipPrev());
document.getElementById('btn-next').addEventListener('click', () => pageFlip.flipNext());
// Keyboard
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return; // Don't flip when typing
if (e.key === 'ArrowLeft') pageFlip.flipPrev();
if (e.key === 'ArrowRight') pageFlip.flipNext();
});
// --- Search Implementation ---
setupSearch();
} catch (error) {
console.error(error);
loader.innerHTML = '<div class="text-danger p-3">Error: ' + error.message + '</div>';
}
});
// --- Search Logic ---
function setupSearch() {
const input = document.getElementById('searchInput');
const btnPrev = document.getElementById('btn-search-prev');
const btnNext = document.getElementById('btn-search-next');
const status = document.getElementById('search-status');
let debounceTimer;
input.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
performSearch(e.target.value.trim());
}, 600);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
clearTimeout(debounceTimer);
performSearch(input.value.trim());
}
});
btnNext.addEventListener('click', () => {
if (searchResults.length === 0) return;
currentMatchIndex = (currentMatchIndex + 1) % searchResults.length;
showMatch(currentMatchIndex);
});
btnPrev.addEventListener('click', () => {
if (searchResults.length === 0) return;
currentMatchIndex = (currentMatchIndex - 1 + searchResults.length) % searchResults.length;
showMatch(currentMatchIndex);
});
}
async function performSearch(query) {
const status = document.getElementById('search-status');
// Clear previous highlights
Object.values(pageHighlighters).forEach(ctx => {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
});
if (!query || query.length < 2) {
searchResults = [];
currentMatchIndex = -1;
status.textContent = '';
return;
}
status.textContent = 'Searching...';
searchResults = [];
currentMatchIndex = -1;
try {
// Loop all pages
for (let i = 1; i <= pdfDoc.numPages; i++) {
const page = await pdfDoc.getPage(i);
const textContent = await page.getTextContent();
const viewport = pageViewports[i]; // Use stored viewport
const ctx = pageHighlighters[i];
// Simple Item Search
// Note: This matches text *within* individual text items.
// Complex multi-item matches (spanning lines/chunks) are harder.
textContent.items.forEach(item => {
if (item.str.toLowerCase().includes(query.toLowerCase())) {
// Found a match!
// item.transform is [scaleX, skewY, skewX, scaleY, tx, ty]
// The PDF coordinate system origin is bottom-left (usually).
// Calculate approximate bounding box
// We need the width of the item. 'item.width' is available in recent pdf.js
// If not, we estimate.
const tx = item.transform;
// Basic rect in PDF coords:
// x = tx[4], y = tx[5]
// w = item.width, h = item.height (or font size?)
// Note: item.height is often 0 or undefined in raw items, use tx[3] (scaleY) as approx font height
let x = tx[4];
let y = tx[5];
let w = item.width;
let h = Math.sqrt(tx[0]*tx[0] + tx[1]*tx[1]); // approximate font size from scale
if (!w) w = h * item.str.length * 0.5; // fallback
// Adjust for y-flip?
// pdf.js viewport.convertToViewportRectangle handles the coordinate transform
// including the y-flip if the viewport is set up that way.
// Text items are bottom-left origin?
// We usually need to move y up by 'h' because rects are top-left?
// Actually, let's just transform (x, y) and (x+w, y+h)
// PDF text is usually baseline.
// So the rect starts at y (baseline) and goes up by h?
// Or starts at y-h?
// Let's assume y is baseline.
const rect = [x, y, x + w, y + h];
const viewRect = viewport.convertToViewportRectangle(rect);
// viewRect is [x1, y1, x2, y2]
// normalize
const rx = Math.min(viewRect[0], viewRect[2]);
const ry = Math.min(viewRect[1], viewRect[3]);
const rw = Math.abs(viewRect[0] - viewRect[2]);
const rh = Math.abs(viewRect[1] - viewRect[3]);
// Draw highlight (Yellow, 40% opacity)
ctx.fillStyle = 'rgba(255, 235, 59, 0.4)';
ctx.fillRect(rx, ry, rw, rh);
// Add border for visibility
ctx.strokeStyle = 'rgba(255, 193, 7, 0.8)';
ctx.lineWidth = 1;
ctx.strokeRect(rx, ry, rw, rh);
// Add to results (one per page is enough for navigation, but we highlight all)
// We only push to searchResults if it's the *first* match on this page
// OR we push every match?
// Let's push every match for "Next" button granularity.
searchResults.push({
pageIndex: i - 1, // 0-based for PageFlip
label: `Page ${i}`
});
}
});
}
if (searchResults.length > 0) {
currentMatchIndex = 0;
status.textContent = `${searchResults.length} matches`;
showMatch(0);
} else {
status.textContent = 'No matches';
}
} catch (e) {
console.error('Search failed', e);
status.textContent = 'Error';
}
}
function showMatch(index) {
if (index < 0 || index >= searchResults.length) return;
const match = searchResults[index];
// Update status text
document.getElementById('search-status').textContent = `${index + 1} / ${searchResults.length}`;
// Flip to page
// PageFlip 0-based index
// Check if we are already there to avoid animation loop?
// PageFlip handles it.
// Note: If view is 'spread', we might need to check if the page is visible.
// But flip() is safe.
pageFlip.flip(match.pageIndex);
}
</script>
</body>
</html>