RevolutionCrossroads/ocr_correction
0
1<!DOCTYPE html>2<html lang="en">3<head>4 <meta charset="UTF-8">5 <meta name="viewport" content="width=device-width, initial-scale=1.0">6 <title>Historical Newspaper OCR Viewer</title>7 8 <!-- PDF.js Library -->9 <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>10 11 <style>12 * {13 margin: 0;14 padding: 0;15 box-sizing: border-box;16 }17 18 body {19 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;20 height: 100vh;21 overflow: hidden;22 background-color: #f5f5f5;23 }24 25 .header {26 background-color: #2c3e50;27 color: white;28 padding: 15px 20px;29 box-shadow: 0 2px 4px rgba(0,0,0,0.1);30 }31 32 .header h1 {33 font-size: 24px;34 margin-bottom: 10px;35 }36 37 .controls {38 display: flex;39 gap: 15px;40 align-items: center;41 flex-wrap: wrap;42 }43 44 .select-container {45 flex: 1;46 max-width: 800px;47 }48 49 .select-container select {50 width: 100%;51 padding: 8px 12px;52 border: 1px solid #ddd;53 border-radius: 4px;54 font-size: 14px;55 background-color: white;56 }57 58 .metadata {59 background-color: #ecf0f1;60 padding: 15px 20px;61 border-bottom: 1px solid #bdc3c7;62 display: none;63 }64 65 .metadata.active {66 display: block;67 }68 69 .metadata-content {70 display: flex;71 gap: 20px;72 align-items: center;73 flex-wrap: wrap;74 }75 76 .metadata-item {77 font-size: 14px;78 }79 80 .metadata-item strong {81 color: #2c3e50;82 }83 84 .metadata-item a {85 color: #3498db;86 text-decoration: none;87 }88 89 .metadata-item a:hover {90 text-decoration: underline;91 }92 93 .main-container {94 display: flex;95 height: calc(100vh - 180px);96 background-color: white;97 }98 99 .pdf-panel, .text-panel {100 width: 50%;101 display: flex;102 flex-direction: column;103 border-right: 1px solid #ddd;104 }105 106 .text-panel {107 border-right: none;108 }109 110 .panel-header {111 background-color: #34495e;112 color: white;113 padding: 12px 20px;114 font-weight: 600;115 font-size: 14px;116 display: flex;117 justify-content: space-between;118 align-items: center;119 }120 121 /* Search Toolbar Styles */122 .search-toolbar {123 background-color: #ecf0f1;124 padding: 8px 15px;125 display: flex;126 gap: 8px;127 align-items: center;128 border-bottom: 1px solid #bdc3c7;129 }130 131 .search-toolbar input[type="text"] {132 padding: 4px 8px;133 border: 1px solid #bdc3c7;134 border-radius: 3px;135 font-size: 13px;136 width: 140px;137 }138 139 .search-toolbar button {140 padding: 4px 10px;141 background-color: #fff;142 border: 1px solid #bdc3c7;143 border-radius: 3px;144 cursor: pointer;145 font-size: 12px;146 color: #2c3e50;147 transition: all 0.2s;148 }149 150 .search-toolbar button:hover:not(:disabled) {151 background-color: #e8e8e8;152 border-color: #95a5a6;153 }154 155 .search-toolbar button:disabled {156 opacity: 0.5;157 cursor: not-allowed;158 }159 160 .search-toolbar label {161 display: flex;162 align-items: center;163 gap: 4px;164 font-size: 12px;165 color: #2c3e50;166 cursor: pointer;167 user-select: none;168 }169 170 .search-toolbar input[type="checkbox"] {171 cursor: pointer;172 }173 174 .search-msg {175 font-size: 12px;176 color: #7f8c8d;177 margin-left: auto;178 }179 180 .pdf-controls {181 display: flex;182 gap: 10px;183 align-items: center;184 }185 186 .zoom-btn {187 background-color: #2c3e50;188 color: white;189 border: none;190 padding: 5px 12px;191 border-radius: 4px;192 cursor: pointer;193 font-size: 16px;194 font-weight: bold;195 transition: background-color 0.2s;196 min-width: 32px;197 }198 199 .zoom-btn:hover:not(:disabled) {200 background-color: #1a252f;201 }202 203 .zoom-btn:disabled {204 background-color: #95a5a6;205 cursor: not-allowed;206 opacity: 0.5;207 }208 209 .zoom-level {210 font-size: 13px;211 min-width: 50px;212 text-align: center;213 }214 215 .ocr-controls {216 display: flex;217 gap: 15px;218 align-items: center;219 }220 221 .ocr-toggle {222 display: flex;223 gap: 10px;224 background-color: #2c3e50;225 padding: 5px;226 border-radius: 4px;227 }228 229 .ocr-toggle label {230 padding: 5px 12px;231 cursor: pointer;232 border-radius: 3px;233 font-size: 13px;234 transition: background-color 0.2s;235 }236 237 .ocr-toggle input[type="radio"] {238 display: none;239 }240 241 .ocr-toggle input[type="radio"]:checked + span {242 background-color: #3498db;243 }244 245 .ocr-toggle span {246 padding: 5px 12px;247 border-radius: 3px;248 font-size: 13px;249 }250 251 .export-btn {252 background-color: #27ae60;253 color: white;254 border: none;255 padding: 6px 16px;256 border-radius: 4px;257 cursor: pointer;258 font-size: 13px;259 font-weight: 600;260 transition: background-color 0.2s;261 }262 263 .export-btn:hover {264 background-color: #229954;265 }266 267 .export-btn:disabled {268 background-color: #95a5a6;269 cursor: not-allowed;270 }271 272 .panel-content {273 flex: 1;274 overflow: auto;275 position: relative;276 }277 278 .panel-content.grabbable {279 cursor: grab;280 }281 282 .panel-content.grabbing {283 cursor: grabbing;284 }285 286 #pdf-canvas {287 display: block;288 margin: 20px auto;289 box-shadow: 0 2px 8px rgba(0,0,0,0.1);290 }291 292 #ocr-text {293 width: 100%;294 height: 100%;295 border: none;296 padding: 20px;297 font-family: 'Courier New', monospace;298 font-size: 14px;299 line-height: 1.6;300 resize: none;301 outline: none;302 }303 304 .loading {305 position: absolute;306 top: 50%;307 left: 50%;308 transform: translate(-50%, -50%);309 text-align: center;310 color: #7f8c8d;311 }312 313 .loading-spinner {314 border: 4px solid #ecf0f1;315 border-top: 4px solid #3498db;316 border-radius: 50%;317 width: 40px;318 height: 40px;319 animation: spin 1s linear infinite;320 margin: 0 auto 10px;321 }322 323 @keyframes spin {324 0% { transform: rotate(0deg); }325 100% { transform: rotate(360deg); }326 }327 328 .error-message {329 color: #e74c3c;330 padding: 20px;331 text-align: center;332 }333 334 .empty-state {335 text-align: center;336 color: #95a5a6;337 padding: 40px;338 font-size: 16px;339 }340 341 .empty-state-icon {342 font-size: 48px;343 margin-bottom: 10px;344 }345 </style>346</head>347<body>348 <div class="header">349 <h1>Historical Newspaper OCR Viewer</h1>350 <div class="controls">351 <div class="select-container">352 <select id="record-select" disabled>353 <option value="">Loading records...</option>354 </select>355 </div>356 </div>357 </div>358 359 <div class="metadata" id="metadata">360 <div class="metadata-content">361 <div class="metadata-item">362 <strong>Title:</strong> <span id="meta-title">-</span>363 </div>364 <div class="metadata-item">365 <strong>Date:</strong> <span id="meta-date">-</span>366 </div>367 <div class="metadata-item">368 <strong>Page:</strong> <span id="meta-page">-</span>369 </div>370 <div class="metadata-item">371 <a id="meta-link" href="#" target="_blank">View on Library of Congress →</a>372 </div>373 </div>374 </div>375 376 <div class="main-container">377 <div class="pdf-panel">378 <div class="panel-header">379 <span>PDF Viewer</span>380 <div class="pdf-controls">381 <button class="zoom-btn" id="zoom-out-btn" disabled title="Zoom Out">−</button>382 <span class="zoom-level" id="zoom-level">100%</span>383 <button class="zoom-btn" id="zoom-in-btn" disabled title="Zoom In">+</button>384 <button class="zoom-btn" id="zoom-reset-btn" disabled title="Reset Zoom">Reset</button>385 </div>386 </div>387 <div class="panel-content" id="pdf-container">388 <div class="empty-state">389 <div class="empty-state-icon">📄</div>390 <div>Select a record to view the PDF</div>391 </div>392 </div>393 </div>394 395 <div class="text-panel">396 <div class="panel-header">397 <span>OCR Text</span>398 <div class="ocr-controls">399 <div class="ocr-toggle">400 <label>401 <input type="radio" name="ocr-source" value="textract" checked>402 <span>New OCR</span>403 </label>404 <label>405 <input type="radio" name="ocr-source" value="loc">406 <span>Original OCR</span>407 </label>408 </div>409 <button class="export-btn" id="export-btn" disabled>Export Text</button>410 </div>411 </div>412 413 <!-- Added Search Toolbar -->414 <div class="search-toolbar">415 <input type="text" id="find-input" placeholder="Find..." disabled>416 <input type="text" id="replace-input" placeholder="Replace with..." disabled>417 <button id="find-next-btn" disabled title="Find Next Occurrence">Find Next</button>418 <button id="replace-btn" disabled title="Replace Current Selection">Replace</button>419 <button id="replace-all-btn" disabled title="Replace All Occurrences">Replace All</button>420 <label>421 <input type="checkbox" id="case-sensitive-cb" disabled>422 Match Case423 </label>424 <span id="search-msg" class="search-msg"></span>425 </div>426 427 <div class="panel-content">428 <textarea 429 id="ocr-text" 430 placeholder="Select a record and OCR source to view text..."431 disabled432 ></textarea>433 </div>434 </div>435 </div>436 437 <script>438 // Configure PDF.js worker439 pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';440 441 // Application state442 const state = {443 records: [],444 currentRecord: null,445 currentOcrSource: 'textract',446 originalOcrText: '',447 hasEdits: false,448 isLoading: false,449 currentPdf: null,450 zoomLevel: 1.0451 };452 453 // DOM elements454 const recordSelect = document.getElementById('record-select');455 const ocrText = document.getElementById('ocr-text');456 const exportBtn = document.getElementById('export-btn');457 const pdfContainer = document.getElementById('pdf-container');458 const metadata = document.getElementById('metadata');459 const ocrRadios = document.querySelectorAll('input[name="ocr-source"]');460 const zoomInBtn = document.getElementById('zoom-in-btn');461 const zoomOutBtn = document.getElementById('zoom-out-btn');462 const zoomResetBtn = document.getElementById('zoom-reset-btn');463 const zoomLevel = document.getElementById('zoom-level');464 465 // Search DOM elements466 const findInput = document.getElementById('find-input');467 const replaceInput = document.getElementById('replace-input');468 const findNextBtn = document.getElementById('find-next-btn');469 const replaceBtn = document.getElementById('replace-btn');470 const replaceAllBtn = document.getElementById('replace-all-btn');471 const caseSensitiveCb = document.getElementById('case-sensitive-cb');472 const searchMsg = document.getElementById('search-msg');473 474 // Initialize application475 async function init() {476 try {477 const response = await fetch('data.json');478 if (!response.ok) {479 throw new Error('Failed to load data.json');480 }481 state.records = await response.json();482 483 populateRecordSelect();484 enableControls();485 setupEventListeners();486 } catch (error) {487 console.error('Initialization error:', error);488 recordSelect.innerHTML = '<option value="">Error loading records</option>';489 alert('Failed to load data.json. Please ensure the file exists in the same directory as index.html.');490 }491 }492 493 function enableControls() {494 recordSelect.disabled = false;495 }496 497 function populateRecordSelect() {498 recordSelect.innerHTML = '<option value="">Select a record...</option>';499 500 state.records.forEach(record => {501 const option = document.createElement('option');502 option.value = record.id;503 option.textContent = `${record.id} - ${record.newspaper_title} ${record.publication_date} Page ${record.page}`;504 recordSelect.appendChild(option);505 });506 }507 508 function setupEventListeners() {509 // Record selection510 recordSelect.addEventListener('change', (e) => {511 const recordId = e.target.value;512 if (recordId) {513 const record = state.records.find(r => r.id === recordId);514 loadRecord(record);515 }516 });517 518 // OCR source toggle519 ocrRadios.forEach(radio => {520 radio.addEventListener('change', (e) => {521 if (state.hasEdits) {522 const confirmed = confirm('You have unsaved edits. Switching OCR source will discard your changes. Continue?');523 if (!confirmed) {524 // Revert to previous selection525 document.querySelector(`input[name="ocr-source"][value="${state.currentOcrSource}"]`).checked = true;526 return;527 }528 }529 state.currentOcrSource = e.target.value;530 if (state.currentRecord) {531 loadOcrText(state.currentRecord);532 }533 });534 });535 536 // Track edits537 ocrText.addEventListener('input', () => {538 state.hasEdits = ocrText.value !== state.originalOcrText;539 });540 541 // Export button542 exportBtn.addEventListener('click', exportText);543 544 // Zoom controls545 zoomInBtn.addEventListener('click', () => zoomPdf(0.25));546 zoomOutBtn.addEventListener('click', () => zoomPdf(-0.25));547 zoomResetBtn.addEventListener('click', () => resetZoom());548 549 // Search controls550 findNextBtn.addEventListener('click', findNext);551 replaceBtn.addEventListener('click', replaceCurrent);552 replaceAllBtn.addEventListener('click', replaceAll);553 554 // Allow "Enter" key in find input to trigger Find Next555 findInput.addEventListener('keypress', (e) => {556 if (e.key === 'Enter') {557 findNext();558 }559 });560 561 // Pan functionality562 setupPanControls();563 }564 565 // --- Search Functions ---566 567 function findNext() {568 const query = findInput.value;569 if (!query) return;570 571 const isCaseSensitive = caseSensitiveCb.checked;572 const text = ocrText.value;573 const startPos = ocrText.selectionEnd; // Start searching from after current cursor/selection574 575 let nextPos = -1;576 577 // Perform search based on case sensitivity578 if (isCaseSensitive) {579 nextPos = text.indexOf(query, startPos);580 if (nextPos === -1) {581 nextPos = text.indexOf(query, 0); // Wrap582 searchMsg.textContent = nextPos !== -1 ? "Wrapped to top" : "Not found";583 } else {584 searchMsg.textContent = "";585 }586 } else {587 const lowerText = text.toLowerCase();588 const lowerQuery = query.toLowerCase();589 nextPos = lowerText.indexOf(lowerQuery, startPos);590 if (nextPos === -1) {591 nextPos = lowerText.indexOf(lowerQuery, 0); // Wrap592 searchMsg.textContent = nextPos !== -1 ? "Wrapped to top" : "Not found";593 } else {594 searchMsg.textContent = "";595 }596 }597 598 if (nextPos !== -1) {599 // Select the found text600 ocrText.focus();601 ocrText.setSelectionRange(nextPos, nextPos + query.length);602 603 // Enhanced Scroll to selection logic604 // Calculate percentage position of the match605 const progress = nextPos / text.length;606 // Set scroll top based on percentage of total scroll height607 // Offset by half container height to try and center it608 const scrollTarget = (ocrText.scrollHeight * progress) - (ocrText.clientHeight / 2);609 610 ocrText.scrollTop = scrollTarget;611 612 // Fallback: Trigger blur/focus which forces browser to scroll to cursor613 // This handles edge cases where the calculation might be slightly off due to variable line wrapping614 // setTimeout ensures the scroll calculation happens first, then the browser creates final view615 setTimeout(() => {616 ocrText.blur();617 ocrText.focus();618 }, 10);619 }620 }621 622 function replaceCurrent() {623 const query = findInput.value;624 const replacement = replaceInput.value;625 if (!query) return;626 627 const isCaseSensitive = caseSensitiveCb.checked;628 629 // Check if current selection matches the find query630 const start = ocrText.selectionStart;631 const end = ocrText.selectionEnd;632 const selectedText = ocrText.value.substring(start, end);633 634 let match = false;635 if (isCaseSensitive) {636 match = selectedText === query;637 } else {638 match = selectedText.toLowerCase() === query.toLowerCase();639 }640 641 if (match) {642 // Perform replacement643 ocrText.setRangeText(replacement, start, end, 'end');644 state.hasEdits = true;645 searchMsg.textContent = "Replaced";646 647 // Automatically find next648 findNext();649 } else {650 // If text isn't selected or doesn't match, try to find next occurrence first651 findNext();652 }653 }654 655 function replaceAll() {656 const query = findInput.value;657 const replacement = replaceInput.value;658 if (!query) return;659 660 const isCaseSensitive = caseSensitiveCb.checked;661 const text = ocrText.value;662 663 // Escape special regex characters to perform a literal "Replace All"664 const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 665 const flags = isCaseSensitive ? 'g' : 'gi';666 const regex = new RegExp(escapedQuery, flags);667 668 const matchCount = (text.match(regex) || []).length;669 670 if (matchCount > 0) {671 const newText = text.replace(regex, replacement);672 ocrText.value = newText;673 state.hasEdits = true;674 searchMsg.textContent = `Replaced ${matchCount} occurrences`;675 } else {676 searchMsg.textContent = "0 matches found";677 }678 }679 680 function updateSearchControls(enabled) {681 findInput.disabled = !enabled;682 replaceInput.disabled = !enabled;683 findNextBtn.disabled = !enabled;684 replaceBtn.disabled = !enabled;685 replaceAllBtn.disabled = !enabled;686 caseSensitiveCb.disabled = !enabled;687 if (!enabled) {688 findInput.value = '';689 replaceInput.value = '';690 caseSensitiveCb.checked = false;691 searchMsg.textContent = '';692 }693 }694 695 // --- End Search Functions ---696 697 async function loadRecord(record) {698 state.currentRecord = record;699 state.hasEdits = false;700 701 // Update metadata702 metadata.classList.add('active');703 document.getElementById('meta-title').textContent = record.newspaper_title;704 document.getElementById('meta-date').textContent = record.publication_date;705 document.getElementById('meta-page').textContent = record.page;706 document.getElementById('meta-link').href = record.loc_record_url;707 708 // Load PDF and OCR text in parallel709 await Promise.all([710 loadPdf(record.pdf_path),711 loadOcrText(record)712 ]);713 }714 715 async function loadPdf(pdfPath) {716 pdfContainer.innerHTML = '<div class="loading"><div class="loading-spinner"></div><div>Loading PDF...</div></div>';717 718 try {719 const loadingTask = pdfjsLib.getDocument(pdfPath);720 const pdf = await loadingTask.promise;721 state.currentPdf = pdf;722 state.zoomLevel = 1.0;723 724 await renderPdf();725 726 // Enable zoom controls727 zoomInBtn.disabled = false;728 zoomOutBtn.disabled = false;729 zoomResetBtn.disabled = false;730 731 // Enable pan controls732 pdfContainer.classList.add('grabbable');733 } catch (error) {734 console.error('PDF loading error:', error);735 pdfContainer.innerHTML = '<div class="error-message">Failed to load PDF: ' + error.message + '</div>';736 }737 }738 739 async function renderPdf() {740 if (!state.currentPdf) return;741 742 pdfContainer.innerHTML = '<div class="loading"><div class="loading-spinner"></div><div>Rendering PDF...</div></div>';743 744 const page = await state.currentPdf.getPage(1);745 const baseScale = 1.5;746 const scale = baseScale * state.zoomLevel;747 const viewport = page.getViewport({ scale });748 749 const canvas = document.createElement('canvas');750 canvas.id = 'pdf-canvas';751 const context = canvas.getContext('2d');752 canvas.height = viewport.height;753 canvas.width = viewport.width;754 755 const renderContext = {756 canvasContext: context,757 viewport: viewport758 };759 760 await page.render(renderContext).promise;761 762 pdfContainer.innerHTML = '';763 pdfContainer.appendChild(canvas);764 765 // Update zoom level display766 zoomLevel.textContent = Math.round(state.zoomLevel * 100) + '%';767 }768 769 async function loadOcrText(record) {770 ocrText.value = '';771 ocrText.disabled = true;772 exportBtn.disabled = true;773 updateSearchControls(false); // Disable search controls while loading774 state.hasEdits = false;775 776 const loadingDiv = document.createElement('div');777 loadingDiv.className = 'loading';778 loadingDiv.innerHTML = '<div class="loading-spinner"></div><div>Loading OCR text...</div>';779 ocrText.parentElement.appendChild(loadingDiv);780 781 try {782 let text = '';783 784 if (state.currentOcrSource === 'textract') {785 text = await fetchTextractOcr(record.textract_ocr_file);786 } else {787 text = await fetchLocOcr(record.loc_altoxml_path);788 }789 790 state.originalOcrText = text;791 ocrText.value = text;792 ocrText.disabled = false;793 exportBtn.disabled = false;794 updateSearchControls(true); // Enable search controls795 loadingDiv.remove();796 } catch (error) {797 console.error('OCR loading error:', error);798 loadingDiv.innerHTML = '<div class="error-message">Failed to load OCR text: ' + error.message + '</div>';799 setTimeout(() => loadingDiv.remove(), 3000);800 }801 }802 803 async function fetchTextractOcr(filePath) {804 const response = await fetch(filePath);805 if (!response.ok) {806 throw new Error(`HTTP error! status: ${response.status}`);807 }808 return await response.text();809 }810 811 async function fetchLocOcr(xmlPath) {812 const response = await fetch(xmlPath);813 if (!response.ok) {814 throw new Error(`HTTP error! status: ${response.status}`);815 }816 const xmlText = await response.text();817 return parseAltoXml(xmlText);818 }819 820 function parseAltoXml(xmlText) {821 const parser = new DOMParser();822 const xmlDoc = parser.parseFromString(xmlText, 'text/xml');823 824 // Check for parsing errors825 const parserError = xmlDoc.querySelector('parsererror');826 if (parserError) {827 throw new Error('XML parsing error');828 }829 830 const strings = xmlDoc.getElementsByTagName('String');831 const textLines = [];832 let currentLine = [];833 let lastVPos = null;834 835 for (let i = 0; i < strings.length; i++) {836 const stringElement = strings[i];837 const content = stringElement.getAttribute('CONTENT');838 const vpos = stringElement.getAttribute('VPOS');839 840 if (content) {841 // Check if we're on a new line (VPOS changed significantly)842 if (lastVPos !== null && vpos !== null) {843 const vposDiff = Math.abs(parseInt(vpos) - parseInt(lastVPos));844 if (vposDiff > 50) { // Threshold for new line845 if (currentLine.length > 0) {846 textLines.push(currentLine.join(' '));847 currentLine = [];848 }849 }850 }851 852 currentLine.push(content);853 lastVPos = vpos;854 }855 }856 857 // Add the last line858 if (currentLine.length > 0) {859 textLines.push(currentLine.join(' '));860 }861 862 return textLines.join('\n');863 }864 865 function exportText() {866 if (!state.currentRecord) return;867 868 const text = ocrText.value;869 const record = state.currentRecord;870 871 // Sanitize filename872 const sanitizedTitle = record.newspaper_title873 .replace(/[^a-z0-9]/gi, '_')874 .replace(/_+/g, '_')875 .toLowerCase();876 877 const filename = `${record.id}_${sanitizedTitle}_${record.publication_date}.txt`;878 879 // Create blob and download880 const blob = new Blob([text], { type: 'text/plain' });881 const url = URL.createObjectURL(blob);882 const a = document.createElement('a');883 a.href = url;884 a.download = filename;885 document.body.appendChild(a);886 a.click();887 document.body.removeChild(a);888 URL.revokeObjectURL(url);889 }890 891 function zoomPdf(delta) {892 // Store current scroll position as percentage893 const scrollLeftPercent = pdfContainer.scrollLeft / (pdfContainer.scrollWidth - pdfContainer.clientWidth);894 const scrollTopPercent = pdfContainer.scrollTop / (pdfContainer.scrollHeight - pdfContainer.clientHeight);895 896 // Update zoom level (limit between 0.5x and 5x)897 state.zoomLevel = Math.max(0.5, Math.min(5, state.zoomLevel + delta));898 899 // Re-render PDF900 renderPdf().then(() => {901 // Restore scroll position902 pdfContainer.scrollLeft = scrollLeftPercent * (pdfContainer.scrollWidth - pdfContainer.clientWidth);903 pdfContainer.scrollTop = scrollTopPercent * (pdfContainer.scrollHeight - pdfContainer.clientHeight);904 });905 }906 907 function resetZoom() {908 state.zoomLevel = 1.0;909 renderPdf();910 }911 912 function setupPanControls() {913 let isMouseDown = false;914 let startX, startY, scrollLeft, scrollTop;915 916 pdfContainer.addEventListener('mousedown', (e) => {917 // Only pan if we clicked on the container or canvas918 if (e.target === pdfContainer || e.target.id === 'pdf-canvas') {919 isMouseDown = true;920 pdfContainer.classList.remove('grabbable');921 pdfContainer.classList.add('grabbing');922 923 startX = e.pageX - pdfContainer.offsetLeft;924 startY = e.pageY - pdfContainer.offsetTop;925 scrollLeft = pdfContainer.scrollLeft;926 scrollTop = pdfContainer.scrollTop;927 }928 });929 930 pdfContainer.addEventListener('mouseleave', () => {931 if (isMouseDown) {932 isMouseDown = false;933 pdfContainer.classList.remove('grabbing');934 pdfContainer.classList.add('grabbable');935 }936 });937 938 pdfContainer.addEventListener('mouseup', () => {939 if (isMouseDown) {940 isMouseDown = false;941 pdfContainer.classList.remove('grabbing');942 pdfContainer.classList.add('grabbable');943 }944 });945 946 pdfContainer.addEventListener('mousemove', (e) => {947 if (!isMouseDown) return;948 e.preventDefault();949 950 const x = e.pageX - pdfContainer.offsetLeft;951 const y = e.pageY - pdfContainer.offsetTop;952 const walkX = (x - startX) * 1.5; // Multiply for faster scrolling953 const walkY = (y - startY) * 1.5;954 955 pdfContainer.scrollLeft = scrollLeft - walkX;956 pdfContainer.scrollTop = scrollTop - walkY;957 });958 }959 960 // Initialize on page load961 init();962 </script>963</body>964</html>