import { showLoader, hideLoader, showAlert } from '../ui.ts'; import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js'; import { icons, createIcons } from 'lucide'; import * as pdfjsLib from 'pdfjs-dist'; import { CompareState } from '@/types'; import type { CompareFilterType, ComparePageResult, CompareTextChange, CompareCategoryFilterState, } from '../compare/types.ts'; import { extractDocumentSignatures } from '../compare/engine/page-signatures.ts'; import { pairPagesAsync } from '../compare/worker-api.ts'; import type { ComparePdfExportMode, CompareCaches, CompareRenderContext, } from '../compare/types.ts'; import { exportComparePdf } from '../compare/reporting/export-compare-pdf.ts'; import { LRUCache } from '../compare/lru-cache.ts'; import { COMPARE_CACHE_MAX_SIZE } from '../compare/config.ts'; import { getElement, computeComparisonForPair, getComparisonCacheKey, } from './compare-render.ts'; pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url ).toString(); const pageState: CompareState = { pdfDoc1: null, pdfDoc2: null, currentPage: 1, viewMode: 'side-by-side', overlayChangeScope: 'all', overlayDocumentVisible: true, isSyncScroll: true, currentComparison: null, activeChangeIndex: 0, pagePairs: [], activeFilter: 'all', categoryFilter: { text: true, image: true, 'header-footer': true, annotation: true, formatting: true, background: true, }, changeSearchQuery: '', useOcr: true, ocrLanguage: 'eng', zoomLevel: 1.0, }; const caches: CompareCaches = { pageModelCache: new LRUCache(COMPARE_CACHE_MAX_SIZE), comparisonCache: new LRUCache(COMPARE_CACHE_MAX_SIZE), comparisonResultsCache: new LRUCache(COMPARE_CACHE_MAX_SIZE), ocrModelCache: new LRUCache(COMPARE_CACHE_MAX_SIZE), }; const documentNames = { left: 'first.pdf', right: 'second.pdf', }; let renderGeneration = 0; function getActivePair() { return pageState.pagePairs[pageState.currentPage - 1] || null; } function getRenderContext(): CompareRenderContext { return { useOcr: pageState.useOcr, ocrLanguage: pageState.ocrLanguage, viewMode: pageState.viewMode, zoomLevel: pageState.zoomLevel, showLoader, }; } function getEffectiveCategoryFilter(): CompareCategoryFilterState { if ( pageState.viewMode !== 'overlay' || pageState.overlayChangeScope !== 'content-only' ) { return pageState.categoryFilter; } return { ...pageState.categoryFilter, formatting: false, }; } function matchesActiveFilter(change: CompareTextChange) { if (pageState.activeFilter === 'all') { return true; } if (pageState.activeFilter === 'removed') { return change.type === 'removed' || change.type === 'page-removed'; } if (pageState.activeFilter === 'added') { return change.type === 'added' || change.type === 'page-added'; } return change.type === pageState.activeFilter; } function matchesSearch(change: CompareTextChange, searchQuery: string) { if (!searchQuery) { return true; } const searchableText = [ change.description, change.beforeText, change.afterText, ] .join(' ') .toLowerCase(); return searchableText.includes(searchQuery); } function shouldIncludeChange( change: CompareTextChange, options: { includeSearch: boolean } ) { if (!matchesActiveFilter(change)) { return false; } const effectiveCategoryFilter = getEffectiveCategoryFilter(); if (!effectiveCategoryFilter[change.category]) { return false; } return options.includeSearch ? matchesSearch(change, pageState.changeSearchQuery.trim().toLowerCase()) : true; } function getVisibleChanges(result: ComparePageResult | null) { if (!result) return []; return result.changes.filter((change) => shouldIncludeChange(change, { includeSearch: true }) ); } function updateFilterButtons() { const pills: Array<{ id: string; filter: CompareFilterType }> = [ { id: 'filter-modified', filter: 'modified' }, { id: 'filter-added', filter: 'added' }, { id: 'filter-removed', filter: 'removed' }, { id: 'filter-moved', filter: 'moved' }, { id: 'filter-style-changed', filter: 'style-changed' }, ]; pills.forEach(({ id, filter }) => { const button = getElement(id); if (!button) return; button.classList.toggle('active', pageState.activeFilter === filter); const isDisabled = id === 'filter-style-changed' && pageState.viewMode === 'overlay' && pageState.overlayChangeScope === 'content-only'; button.disabled = isDisabled; button.classList.toggle('opacity-50', isDisabled); button.classList.toggle('cursor-not-allowed', isDisabled); }); } function updateOverlayScopeButtons() { const allButton = getElement('overlay-scope-all'); const contentOnlyButton = getElement( 'overlay-scope-content-only' ); const applyState = (button: HTMLButtonElement | null, active: boolean) => { if (!button) return; button.classList.toggle('bg-indigo-600', active); button.classList.toggle('bg-gray-700', !active); }; applyState(allButton, pageState.overlayChangeScope === 'all'); applyState( contentOnlyButton, pageState.overlayChangeScope === 'content-only' ); } function updateExportMenuForViewMode() { const overlayItems = document.querySelectorAll('.export-menu-item-overlay'); const sideItems = document.querySelectorAll('.export-menu-item-side'); const isOverlay = pageState.viewMode === 'overlay'; overlayItems.forEach((item) => { item.classList.toggle('hidden', !isOverlay); }); sideItems.forEach((item) => { item.classList.toggle('hidden', isOverlay); }); } function updateOverlayPreviewState() { const canvas2 = getElement('canvas-compare-2'); const panel2 = getElement('panel-2'); const opacitySlider = getElement('opacity-slider'); const activePair = getActivePair(); const hasLeftPage = Boolean(activePair?.leftPageNumber); const hasRightPage = Boolean(activePair?.rightPageNumber); if (!canvas2 || !panel2) { return; } if (pageState.viewMode !== 'overlay') { canvas2.style.opacity = '1'; panel2.style.opacity = '1'; return; } panel2.style.opacity = '1'; if (!hasRightPage) { canvas2.style.opacity = '0'; return; } if (!hasLeftPage) { canvas2.style.opacity = '1'; return; } if (pageState.overlayChangeScope === 'content-only') { canvas2.style.opacity = '0'; return; } canvas2.style.opacity = pageState.overlayDocumentVisible ? opacitySlider?.value || '0.5' : '0'; } function updateSummary() { const comparison = pageState.currentComparison; const addedCount = getElement('summary-added-count'); const removedCount = getElement('summary-removed-count'); const modifiedCount = getElement('summary-modified-count'); const movedCount = getElement('summary-moved-count'); const styleChangedCount = getElement( 'summary-style-changed-count' ); const panelLabel1 = getElement('compare-panel-label-1'); const panelLabel2 = getElement('compare-panel-label-2'); if (panelLabel1) panelLabel1.textContent = documentNames.left; if (panelLabel2) panelLabel2.textContent = documentNames.right; if (!comparison) { if (addedCount) addedCount.textContent = '0'; if (removedCount) removedCount.textContent = '0'; if (modifiedCount) modifiedCount.textContent = '0'; if (movedCount) movedCount.textContent = '0'; if (styleChangedCount) styleChangedCount.textContent = '0'; updateCategoryPills(null); return; } if (addedCount) addedCount.textContent = comparison.summary.added.toString(); if (removedCount) removedCount.textContent = comparison.summary.removed.toString(); if (modifiedCount) modifiedCount.textContent = comparison.summary.modified.toString(); if (movedCount) movedCount.textContent = comparison.summary.moved.toString(); if (styleChangedCount) styleChangedCount.textContent = comparison.summary.styleChanged.toString(); updateCategoryPills(comparison); } function updateCategoryPills(comparison: ComparePageResult | null) { const categoryKeys: Array = [ 'text', 'image', 'header-footer', 'annotation', 'formatting', 'background', ]; const summary = comparison?.categorySummary; const effectiveCategoryFilter = getEffectiveCategoryFilter(); for (const key of categoryKeys) { const countEl = getElement(`category-count-${key}`); const pill = getElement(`category-${key}`); if (countEl) countEl.textContent = summary ? summary[key].toString() : '0'; if (pill) { pill.classList.toggle('active', effectiveCategoryFilter[key]); pill.classList.toggle('disabled', !effectiveCategoryFilter[key]); } } } function renderHighlights() { const highlightLayer1 = getElement('highlights-1'); const highlightLayer2 = getElement('highlights-2'); if (!highlightLayer1 || !highlightLayer2) return; highlightLayer1.innerHTML = ''; highlightLayer2.innerHTML = ''; const comparison = pageState.currentComparison; if (!comparison) return; getVisibleChanges(comparison).forEach((change, index) => { const activeClass = index === pageState.activeChangeIndex ? ' active' : ''; change.beforeRects.forEach((rect) => { const marker = document.createElement('div'); marker.className = `compare-highlight ${change.type}${activeClass}`; marker.style.left = `${rect.x}px`; marker.style.top = `${rect.y}px`; marker.style.width = `${rect.width}px`; marker.style.height = `${rect.height}px`; highlightLayer1.appendChild(marker); }); change.afterRects.forEach((rect) => { const marker = document.createElement('div'); marker.className = `compare-highlight ${change.type}${activeClass}`; marker.style.left = `${rect.x}px`; marker.style.top = `${rect.y}px`; marker.style.width = `${rect.width}px`; marker.style.height = `${rect.height}px`; highlightLayer2.appendChild(marker); }); }); } function scrollToChange(change: CompareTextChange) { const panel1 = getElement('panel-1'); const panel2 = getElement('panel-2'); const firstBefore = change.beforeRects[0]; const firstAfter = change.afterRects[0]; if (panel1 && firstBefore) { panel1.scrollTo({ top: Math.max(firstBefore.y - 40, 0), behavior: 'smooth', }); } if (panel2 && firstAfter) { panel2.scrollTo({ top: Math.max(firstAfter.y - 40, 0), behavior: 'smooth', }); } } function renderChangeList() { const comparison = pageState.currentComparison; const list = getElement('compare-change-list'); const emptyState = getElement('change-list-empty'); const prevChangeBtn = getElement('prev-change-btn'); const nextChangeBtn = getElement('next-change-btn'); const exportDropdownBtn = getElement( 'export-dropdown-btn' ); if ( !list || !emptyState || !prevChangeBtn || !nextChangeBtn || !exportDropdownBtn ) return; list.innerHTML = ''; const visibleChanges = getVisibleChanges(comparison); if (!comparison || visibleChanges.length === 0) { emptyState.textContent = comparison?.status === 'match' ? 'No differences detected on this page.' : 'No changes match the current filter.'; emptyState.classList.remove('hidden'); list.classList.add('hidden'); prevChangeBtn.disabled = true; nextChangeBtn.disabled = true; exportDropdownBtn.disabled = pageState.pagePairs.length === 0; return; } emptyState.classList.add('hidden'); list.classList.remove('hidden'); const typeLabels: Record = { added: 'Added', removed: 'Deleted', modified: 'Modified', moved: 'Moved', 'style-changed': 'Style Changed', 'page-added': 'Page Added', 'page-removed': 'Page Removed', }; const grouped = new Map< string, Array<{ change: CompareTextChange; index: number }> >(); visibleChanges.forEach((change, index) => { const key = change.type; if (!grouped.has(key)) grouped.set(key, []); grouped.get(key)!.push({ change, index }); }); for (const [type, entries] of grouped) { const header = document.createElement('div'); header.className = 'compare-section-header'; header.innerHTML = ` ${entries.length} `; list.appendChild(header); const arrowSvg = ''; for (const { change, index } of entries) { const item = document.createElement('div'); item.className = `compare-change-item${index === pageState.activeChangeIndex ? ' active' : ''}`; const safeDesc = change.description .replace(/&/g, '&') .replace(//g, '>') .replace(/\n/g, '
') .replace(/→/g, arrowSvg); item.innerHTML = `
${safeDesc}
`; item.addEventListener('click', function () { pageState.activeChangeIndex = index; renderComparisonUI(); scrollToChange(change); }); list.appendChild(item); } } prevChangeBtn.disabled = false; nextChangeBtn.disabled = false; exportDropdownBtn.disabled = pageState.pagePairs.length === 0; } function renderComparisonUI() { updateOverlayScopeButtons(); updateExportMenuForViewMode(); updateFilterButtons(); renderHighlights(); renderChangeList(); updateSummary(); updateOverlayPreviewState(); syncComparePaneHeights(); } function syncComparePaneHeights() { const wrapper = getElement('compare-viewer-wrapper'); const sidebar = document.querySelector('.compare-sidebar'); if (!wrapper || !sidebar) { return; } if (window.innerWidth <= 1023) { wrapper.style.height = ''; return; } wrapper.style.height = `${sidebar.offsetHeight}px`; } async function buildPagePairs() { if (!pageState.pdfDoc1 || !pageState.pdfDoc2) return; showLoader('Building page pairing model...', 0); const leftSignatures = await extractDocumentSignatures( pageState.pdfDoc1, function (pageNumber, totalPages) { showLoader( `Indexing PDF 1 page ${pageNumber} of ${totalPages}...`, (pageNumber / Math.max(totalPages * 2, 1)) * 100 ); } ); const rightSignatures = await extractDocumentSignatures( pageState.pdfDoc2, function (pageNumber, totalPages) { showLoader( `Indexing PDF 2 page ${pageNumber} of ${totalPages}...`, 50 + (pageNumber / Math.max(totalPages * 2, 1)) * 100 ); } ); pageState.pagePairs = await pairPagesAsync(leftSignatures, rightSignatures); pageState.currentPage = 1; } async function buildReportResults() { const results: ComparePageResult[] = []; const ctx = getRenderContext(); for (const pair of pageState.pagePairs) { const cached = caches.comparisonResultsCache.get(pair.pairIndex); if (cached) { results.push(cached); continue; } const cacheKey = getComparisonCacheKey(pair, pageState.useOcr); const cachedResult = caches.comparisonCache.get(cacheKey); if (cachedResult) { results.push(cachedResult); continue; } const comparison = await computeComparisonForPair( pageState.pdfDoc1, pageState.pdfDoc2, pair, caches, ctx ); caches.comparisonCache.set(cacheKey, comparison); caches.comparisonResultsCache.set(pair.pairIndex, comparison); results.push(comparison); } return results; } async function renderBothPages() { if (!pageState.pdfDoc1 || !pageState.pdfDoc2) return; const pair = getActivePair(); if (!pair) return; const gen = ++renderGeneration; showLoader( `Loading comparison ${pageState.currentPage} of ${pageState.pagePairs.length}...` ); const canvas1 = getElement( 'canvas-compare-1' ) as HTMLCanvasElement; const canvas2 = getElement( 'canvas-compare-2' ) as HTMLCanvasElement; const panel1 = getElement('panel-1') as HTMLElement; const panel2 = getElement('panel-2') as HTMLElement; const container1 = panel1; const container2 = pageState.viewMode === 'overlay' ? panel1 : panel2; const ctx = getRenderContext(); const comparison = await computeComparisonForPair( pageState.pdfDoc1, pageState.pdfDoc2, pair, caches, ctx, { renderTargets: { left: { canvas: canvas1, container: container1, placeholderId: 'placeholder-1', }, right: { canvas: canvas2, container: container2, placeholderId: 'placeholder-2', }, }, } ); if (gen !== renderGeneration) return; pageState.currentComparison = comparison; pageState.activeChangeIndex = 0; updateNavControls(); renderComparisonUI(); hideLoader(); } function updateNavControls() { const totalPairs = pageState.pagePairs.length || Math.max( pageState.pdfDoc1?.numPages || 0, pageState.pdfDoc2?.numPages || 0 ); const currentDisplay = document.getElementById( 'current-page-display-compare' ); const totalDisplay = document.getElementById('total-pages-display-compare'); const prevBtn = document.getElementById( 'prev-page-compare' ) as HTMLButtonElement; const nextBtn = document.getElementById( 'next-page-compare' ) as HTMLButtonElement; if (currentDisplay) currentDisplay.textContent = pageState.currentPage.toString(); if (totalDisplay) totalDisplay.textContent = totalPairs.toString(); if (prevBtn) prevBtn.disabled = pageState.currentPage <= 1; if (nextBtn) nextBtn.disabled = pageState.currentPage >= totalPairs; } function setViewMode(mode: 'overlay' | 'side-by-side') { pageState.viewMode = mode; const wrapper = document.getElementById('compare-viewer-wrapper'); const overlayControls = document.getElementById('overlay-controls'); const sideControls = document.getElementById('side-by-side-controls'); const btnOverlay = document.getElementById('view-mode-overlay'); const btnSide = document.getElementById('view-mode-side'); const canvas2 = getElement( 'canvas-compare-2' ) as HTMLCanvasElement; const opacitySlider = getElement( 'opacity-slider' ) as HTMLInputElement; if (mode === 'overlay') { if (wrapper) wrapper.className = 'compare-viewer-wrapper overlay-mode border border-slate-200'; if (overlayControls) overlayControls.classList.remove('hidden'); if (sideControls) sideControls.classList.add('hidden'); if (btnOverlay) { btnOverlay.classList.add('bg-indigo-600'); btnOverlay.classList.remove('bg-gray-700'); } if (btnSide) { btnSide.classList.remove('bg-indigo-600'); btnSide.classList.add('bg-gray-700'); } if (canvas2 && opacitySlider) { canvas2.style.transition = 'opacity 150ms ease-in-out'; } pageState.isSyncScroll = true; } else { if (wrapper) wrapper.className = 'compare-viewer-wrapper side-by-side-mode border border-slate-200'; if (overlayControls) overlayControls.classList.add('hidden'); if (sideControls) sideControls.classList.remove('hidden'); if (btnOverlay) { btnOverlay.classList.remove('bg-indigo-600'); btnOverlay.classList.add('bg-gray-700'); } if (btnSide) { btnSide.classList.add('bg-indigo-600'); btnSide.classList.remove('bg-gray-700'); } if (canvas2) canvas2.style.opacity = '1'; const panel2 = getElement('panel-2'); if (panel2) panel2.style.opacity = '1'; } updateOverlayScopeButtons(); updateExportMenuForViewMode(); updateOverlayPreviewState(); syncComparePaneHeights(); const p1 = getElement('panel-1'); const p2 = getElement('panel-2'); if (mode === 'overlay' && p1 && p2) { p2.scrollTop = p1.scrollTop; p2.scrollLeft = p1.scrollLeft; } if (pageState.pdfDoc1 && pageState.pdfDoc2) { renderBothPages(); } } async function handleFileInput( inputId: string, docKey: 'pdfDoc1' | 'pdfDoc2', displayId: string ) { const fileInput = document.getElementById(inputId) as HTMLInputElement; const dropZone = document.getElementById(`drop-zone-${inputId.slice(-1)}`); async function handleFile(file: File) { if (!file || file.type !== 'application/pdf') { showAlert('Invalid File', 'Please select a valid PDF file.'); return; } const displayDiv = document.getElementById(displayId); if (displayDiv) { displayDiv.innerHTML = ''; const icon = document.createElement('i'); icon.setAttribute('data-lucide', 'check-circle'); icon.className = 'w-10 h-10 mb-3 text-green-500'; const p = document.createElement('p'); p.className = 'text-sm text-gray-300 truncate'; p.textContent = file.name; if (docKey === 'pdfDoc1') documentNames.left = file.name; if (docKey === 'pdfDoc2') documentNames.right = file.name; const panelLabel1 = getElement('compare-panel-label-1'); const panelLabel2 = getElement('compare-panel-label-2'); if (docKey === 'pdfDoc1' && panelLabel1) panelLabel1.textContent = file.name; if (docKey === 'pdfDoc2' && panelLabel2) panelLabel2.textContent = file.name; displayDiv.append(icon, p); createIcons({ icons }); } try { hideLoader(); const result = await loadPdfWithPasswordPrompt(file); if (!result) return; showLoader(`Loading ${result.file.name}...`); pageState[docKey] = result.pdf; caches.pageModelCache.clear(); caches.comparisonCache.clear(); caches.comparisonResultsCache.clear(); pageState.changeSearchQuery = ''; const searchInput = getElement('compare-search-input'); if (searchInput) { searchInput.value = ''; } if (pageState.pdfDoc1 && pageState.pdfDoc2) { const compareViewer = document.getElementById('compare-viewer'); if (compareViewer) compareViewer.classList.remove('hidden'); await buildPagePairs(); await renderBothPages(); } } catch (e) { showAlert( 'Error', 'Could not load PDF. It may be corrupt or password-protected.' ); console.error(e); } finally { hideLoader(); } } if (fileInput) { fileInput.addEventListener('change', function (e) { const files = (e.target as HTMLInputElement).files; if (files && files[0]) handleFile(files[0]); }); } if (dropZone) { dropZone.addEventListener('dragover', function (e) { e.preventDefault(); }); dropZone.addEventListener('drop', function (e) { e.preventDefault(); const files = e.dataTransfer?.files; if (files && files[0]) handleFile(files[0]); }); } } document.addEventListener('DOMContentLoaded', function () { const backBtn = getElement('back-to-tools'); if (backBtn) { backBtn.addEventListener('click', function () { window.location.href = import.meta.env.BASE_URL; }); } handleFileInput('file-input-1', 'pdfDoc1', 'file-display-1'); handleFileInput('file-input-2', 'pdfDoc2', 'file-display-2'); const prevBtn = getElement('prev-page-compare'); const nextBtn = getElement('next-page-compare'); if (prevBtn) { prevBtn.addEventListener('click', function () { if (pageState.currentPage > 1) { pageState.currentPage--; renderBothPages().catch(console.error); } }); } if (nextBtn) { nextBtn.addEventListener('click', function () { const totalPairs = pageState.pagePairs.length || Math.max( pageState.pdfDoc1?.numPages || 0, pageState.pdfDoc2?.numPages || 0 ); if (pageState.currentPage < totalPairs) { pageState.currentPage++; renderBothPages().catch(console.error); } }); } const btnOverlay = getElement('view-mode-overlay'); const btnSide = getElement('view-mode-side'); if (btnOverlay) { btnOverlay.addEventListener('click', function () { setViewMode('overlay'); }); } if (btnSide) { btnSide.addEventListener('click', function () { setViewMode('side-by-side'); }); } const flickerBtn = getElement('flicker-btn'); const canvas2 = getElement( 'canvas-compare-2' ) as HTMLCanvasElement; const opacitySlider = getElement( 'opacity-slider' ) as HTMLInputElement; if (flickerBtn) { flickerBtn.addEventListener('click', function () { pageState.overlayDocumentVisible = !pageState.overlayDocumentVisible; updateOverlayPreviewState(); }); } if (opacitySlider) { opacitySlider.addEventListener('input', function () { pageState.overlayDocumentVisible = true; updateOverlayPreviewState(); }); } const panel1 = getElement('panel-1'); const panel2 = getElement('panel-2'); const syncToggle = getElement( 'sync-scroll-toggle' ) as HTMLInputElement; const prevChangeBtn = getElement('prev-change-btn'); const nextChangeBtn = getElement('next-change-btn'); const exportDropdownBtn = getElement( 'export-dropdown-btn' ); const exportDropdownMenu = getElement('export-dropdown-menu'); const ocrToggle = getElement('ocr-toggle'); const overlayOcrToggle = getElement('overlay-ocr-toggle'); const searchInput = getElement('compare-search-input'); const overlayAllBtn = getElement('overlay-scope-all'); const overlayContentOnlyBtn = getElement( 'overlay-scope-content-only' ); const filterButtons: Array<{ id: string; filter: CompareFilterType }> = [ { id: 'filter-modified', filter: 'modified' }, { id: 'filter-added', filter: 'added' }, { id: 'filter-removed', filter: 'removed' }, { id: 'filter-moved', filter: 'moved' }, { id: 'filter-style-changed', filter: 'style-changed' }, ]; if (syncToggle) { syncToggle.addEventListener('change', function () { pageState.isSyncScroll = syncToggle.checked; }); } let scrollingPanel: HTMLElement | null = null; if (panel1 && panel2) { panel1.addEventListener('scroll', function () { if (pageState.isSyncScroll && scrollingPanel !== panel2) { scrollingPanel = panel1; panel2.scrollTop = panel1.scrollTop; panel2.scrollLeft = panel1.scrollLeft; setTimeout(function () { scrollingPanel = null; }, 100); } }); panel2.addEventListener('scroll', function () { if (pageState.viewMode === 'overlay') return; if (pageState.isSyncScroll && scrollingPanel !== panel1) { scrollingPanel = panel2; panel1.scrollTop = panel2.scrollTop; panel1.scrollLeft = panel2.scrollLeft; setTimeout(function () { scrollingPanel = null; }, 100); } }); } if (prevChangeBtn) { prevChangeBtn.addEventListener('click', function () { const changes = getVisibleChanges(pageState.currentComparison); if (changes.length === 0) return; pageState.activeChangeIndex = (pageState.activeChangeIndex - 1 + changes.length) % changes.length; renderComparisonUI(); scrollToChange(changes[pageState.activeChangeIndex]); }); } if (nextChangeBtn) { nextChangeBtn.addEventListener('click', function () { const changes = getVisibleChanges(pageState.currentComparison); if (changes.length === 0) return; pageState.activeChangeIndex = (pageState.activeChangeIndex + 1) % changes.length; renderComparisonUI(); scrollToChange(changes[pageState.activeChangeIndex]); }); } const ZOOM_STEP = 0.25; const ZOOM_MIN = 0.25; const ZOOM_MAX = 5.0; const zoomInBtn = getElement('zoom-in-btn'); const zoomOutBtn = getElement('zoom-out-btn'); const zoomResetBtn = getElement('zoom-reset-btn'); const zoomDisplay = getElement('zoom-level-display'); function updateZoomDisplay() { if (zoomDisplay) { zoomDisplay.textContent = `${Math.round(pageState.zoomLevel * 100)}%`; } if (zoomOutBtn) zoomOutBtn.disabled = pageState.zoomLevel <= ZOOM_MIN; if (zoomInBtn) zoomInBtn.disabled = pageState.zoomLevel >= ZOOM_MAX; } function applyZoom() { updateZoomDisplay(); caches.pageModelCache.clear(); caches.comparisonCache.clear(); caches.comparisonResultsCache.clear(); if (pageState.pdfDoc1 && pageState.pdfDoc2) { renderBothPages().catch(console.error); } } if (zoomInBtn) { zoomInBtn.addEventListener('click', function () { pageState.zoomLevel = Math.min( Math.round((pageState.zoomLevel + ZOOM_STEP) * 100) / 100, ZOOM_MAX ); applyZoom(); }); } if (zoomOutBtn) { zoomOutBtn.addEventListener('click', function () { pageState.zoomLevel = Math.max( Math.round((pageState.zoomLevel - ZOOM_STEP) * 100) / 100, ZOOM_MIN ); applyZoom(); }); } if (zoomResetBtn) { zoomResetBtn.addEventListener('click', function () { pageState.zoomLevel = 1.0; applyZoom(); }); } filterButtons.forEach(({ id, filter }) => { const button = getElement(id); if (!button) return; button.addEventListener('click', function () { if ( filter === 'style-changed' && pageState.viewMode === 'overlay' && pageState.overlayChangeScope === 'content-only' ) { return; } if (pageState.activeFilter === filter) { pageState.activeFilter = 'all'; } else { pageState.activeFilter = filter; } pageState.activeChangeIndex = 0; renderComparisonUI(); }); }); const categoryKeys: Array = [ 'text', 'image', 'header-footer', 'annotation', 'formatting', 'background', ]; for (const key of categoryKeys) { const pill = getElement(`category-${key}`); if (pill) { pill.addEventListener('click', function () { if ( key === 'formatting' && pageState.viewMode === 'overlay' && pageState.overlayChangeScope === 'content-only' ) { return; } pageState.categoryFilter[key] = !pageState.categoryFilter[key]; pageState.activeChangeIndex = 0; renderComparisonUI(); }); } } if (overlayAllBtn) { overlayAllBtn.addEventListener('click', function () { pageState.overlayChangeScope = 'all'; pageState.activeChangeIndex = 0; pageState.overlayDocumentVisible = true; renderComparisonUI(); }); } if (overlayContentOnlyBtn) { overlayContentOnlyBtn.addEventListener('click', function () { pageState.overlayChangeScope = 'content-only'; if (pageState.activeFilter === 'style-changed') { pageState.activeFilter = 'all'; } pageState.activeChangeIndex = 0; pageState.overlayDocumentVisible = false; renderComparisonUI(); }); } async function handleOcrToggleChange(nextValue: boolean) { try { pageState.useOcr = nextValue; if (ocrToggle) { ocrToggle.checked = nextValue; } if (overlayOcrToggle) { overlayOcrToggle.checked = nextValue; } caches.pageModelCache.clear(); caches.comparisonCache.clear(); caches.comparisonResultsCache.clear(); if (pageState.pdfDoc1 && pageState.pdfDoc2) { await renderBothPages(); } } catch (e) { console.error('OCR toggle failed:', e); hideLoader(); } } if (ocrToggle) { ocrToggle.checked = pageState.useOcr; ocrToggle.addEventListener('change', async function () { await handleOcrToggleChange(ocrToggle.checked); }); } if (overlayOcrToggle) { overlayOcrToggle.checked = pageState.useOcr; overlayOcrToggle.addEventListener('change', async function () { await handleOcrToggleChange(overlayOcrToggle.checked); }); } if (searchInput) { searchInput.addEventListener('input', function () { pageState.changeSearchQuery = searchInput.value; pageState.activeChangeIndex = 0; renderComparisonUI(); }); } let resizeFrame = 0; window.addEventListener('resize', function () { if (!pageState.pdfDoc1 || !pageState.pdfDoc2) { syncComparePaneHeights(); return; } window.cancelAnimationFrame(resizeFrame); resizeFrame = window.requestAnimationFrame(function () { syncComparePaneHeights(); renderBothPages().catch(console.error); }); }); const sidebar = document.querySelector('.compare-sidebar'); if (sidebar && typeof ResizeObserver !== 'undefined') { const resizeObserver = new ResizeObserver(function () { syncComparePaneHeights(); }); resizeObserver.observe(sidebar); } if (exportDropdownBtn && exportDropdownMenu) { exportDropdownBtn.addEventListener('click', function (e) { e.stopPropagation(); exportDropdownMenu.classList.toggle('hidden'); }); document.addEventListener('click', function () { exportDropdownMenu.classList.add('hidden'); }); exportDropdownMenu.addEventListener('click', function (e) { e.stopPropagation(); }); document.querySelectorAll('.export-menu-item').forEach(function (btn) { btn.addEventListener('click', async function () { const mode = (btn as HTMLElement).dataset .exportMode as ComparePdfExportMode; if (!mode || pageState.pagePairs.length === 0) return; exportDropdownMenu.classList.add('hidden'); try { showLoader('Preparing PDF export...'); await exportComparePdf( mode, pageState.pdfDoc1, pageState.pdfDoc2, pageState.pagePairs, function (message, percent) { showLoader(message, percent); }, { useOcr: pageState.useOcr, ocrLanguage: pageState.ocrLanguage, showOverlayDocument: pageState.viewMode === 'overlay' ? pageState.overlayChangeScope === 'all' && pageState.overlayDocumentVisible : undefined, overlayOpacity: pageState.viewMode === 'overlay' ? Number.parseFloat(opacitySlider?.value || '0.5') : undefined, includeChange: (change) => shouldIncludeChange(change, { includeSearch: false }), } ); } catch (e) { console.error('PDF export failed:', e); showAlert('Export Error', 'Could not export comparison PDF.'); } finally { hideLoader(); } }); }); } createIcons({ icons }); updateFilterButtons(); updateOverlayScopeButtons(); updateExportMenuForViewMode(); syncComparePaneHeights(); setViewMode(pageState.viewMode); });