feat: enhance PDF comparison with overlay options and filters

This commit is contained in:
alam00000
2026-03-16 21:47:35 +05:30
parent 477839f106
commit 6c0e9c7232
5 changed files with 454 additions and 85 deletions

View File

@@ -1,18 +1,20 @@
import { PDFDocument, rgb } from 'pdf-lib'; import { PDFDocument, rgb } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
import type { import type {
CompareCaches,
ComparePagePair, ComparePagePair,
CompareTextChange, CompareTextChange,
ComparePdfExportMode, ComparePdfExportMode,
} from '../types.ts'; } from '../types.ts';
import { extractPageModel } from '../engine/extract-page-model.ts';
import { comparePageModelsAsync } from '../engine/compare-page-models.ts';
import { import {
COMPARE_CACHE_MAX_SIZE,
COMPARE_COLORS, COMPARE_COLORS,
HIGHLIGHT_OPACITY, HIGHLIGHT_OPACITY,
COMPARE_RENDER, COMPARE_RENDER,
} from '../config.ts'; } from '../config.ts';
import { downloadFile } from '../../utils/helpers.ts'; import { downloadFile } from '../../utils/helpers.ts';
import { computeComparisonForPair } from '../../logic/compare-render.ts';
import { LRUCache } from '../lru-cache.ts';
const HIGHLIGHT_COLORS: Record< const HIGHLIGHT_COLORS: Record<
string, string,
@@ -56,7 +58,7 @@ const HIGHLIGHT_COLORS: Record<
}, },
}; };
const EXTRACT_SCALE = COMPARE_RENDER.EXPORT_EXTRACT_SCALE; const EXTRACT_SCALE = COMPARE_RENDER.OFFLINE_SCALE;
function drawHighlights( function drawHighlights(
page: ReturnType<PDFDocument['getPage']>, page: ReturnType<PDFDocument['getPage']>,
@@ -86,7 +88,14 @@ export async function exportComparePdf(
pdfDoc1: pdfjsLib.PDFDocumentProxy | null, pdfDoc1: pdfjsLib.PDFDocumentProxy | null,
pdfDoc2: pdfjsLib.PDFDocumentProxy | null, pdfDoc2: pdfjsLib.PDFDocumentProxy | null,
pairs: ComparePagePair[], pairs: ComparePagePair[],
onProgress?: (message: string, percent: number) => void onProgress?: (message: string, percent: number) => void,
options?: {
overlayOpacity?: number;
includeChange?: (change: CompareTextChange) => boolean;
useOcr?: boolean;
ocrLanguage?: string;
showOverlayDocument?: boolean;
}
) { ) {
if (!pdfDoc1 && !pdfDoc2) { if (!pdfDoc1 && !pdfDoc2) {
throw new Error('At least one PDF document is required for export.'); throw new Error('At least one PDF document is required for export.');
@@ -107,6 +116,25 @@ export async function exportComparePdf(
bytes2 ? PDFDocument.load(bytes2, { ignoreEncryption: true }) : null, bytes2 ? PDFDocument.load(bytes2, { ignoreEncryption: true }) : null,
]); ]);
const includeChange = options?.includeChange ?? (() => true);
const overlayOpacity = options?.overlayOpacity ?? 0.5;
const showOverlayDocument = options?.showOverlayDocument ?? true;
const exportCaches: 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 renderContext = {
useOcr: options?.useOcr ?? true,
ocrLanguage: options?.ocrLanguage ?? 'eng',
viewMode: mode === 'overlay' ? 'overlay' : 'side-by-side',
zoomLevel: 1,
showLoader: (message: string, percent?: number) => {
onProgress?.(message, percent ?? 0);
},
} as const;
for (let i = 0; i < pairs.length; i++) { for (let i = 0; i < pairs.length; i++) {
const pair = pairs[i]; const pair = pairs[i];
onProgress?.( onProgress?.(
@@ -123,21 +151,83 @@ export async function exportComparePdf(
? await pdfDoc2.getPage(pair.rightPageNumber) ? await pdfDoc2.getPage(pair.rightPageNumber)
: null; : null;
const leftModel = leftPdjsPage const comparison = await computeComparisonForPair(
? await extractPageModel( pdfDoc1,
leftPdjsPage, pdfDoc2,
leftPdjsPage.getViewport({ scale: EXTRACT_SCALE }) pair,
) exportCaches,
: null; renderContext
const rightModel = rightPdjsPage );
? await extractPageModel( const changes = comparison.changes.filter(includeChange);
rightPdjsPage,
rightPdjsPage.getViewport({ scale: EXTRACT_SCALE })
)
: null;
const comparison = await comparePageModelsAsync(leftModel, rightModel); if (mode === 'overlay') {
const changes = comparison.changes; const leftViewport = leftPdjsPage?.getViewport({ scale: 1.0 }) ?? null;
const rightViewport = rightPdjsPage?.getViewport({ scale: 1.0 }) ?? null;
const pageWidth = leftViewport?.width ?? rightViewport?.width;
const pageHeight = leftViewport?.height ?? rightViewport?.height;
if (!pageWidth || !pageHeight) {
continue;
}
const outPage = outDoc.addPage([pageWidth, pageHeight]);
if (pair.leftPageNumber && libDoc1) {
const [copied] = await outDoc.copyPages(libDoc1, [
pair.leftPageNumber - 1,
]);
const embedded = await outDoc.embedPage(copied);
outPage.drawPage(embedded, {
x: 0,
y: 0,
width: pageWidth,
height: pageHeight,
});
}
if (pair.rightPageNumber && libDoc2) {
const shouldDrawRightPage = !pair.leftPageNumber || showOverlayDocument;
if (shouldDrawRightPage) {
const [copied] = await outDoc.copyPages(libDoc2, [
pair.rightPageNumber - 1,
]);
const embedded = await outDoc.embedPage(copied);
outPage.drawPage(embedded, {
x: 0,
y: 0,
width: pageWidth,
height: pageHeight,
opacity:
pair.leftPageNumber && pair.rightPageNumber && showOverlayDocument
? overlayOpacity
: 1,
});
}
}
if (changes.length) {
for (const change of changes) {
const color = HIGHLIGHT_COLORS[change.type];
if (!color) continue;
for (const rect of [...change.beforeRects, ...change.afterRects]) {
outPage.drawRectangle({
x: rect.x / EXTRACT_SCALE,
y:
pageHeight -
rect.y / EXTRACT_SCALE -
rect.height / EXTRACT_SCALE,
width: rect.width / EXTRACT_SCALE,
height: rect.height / EXTRACT_SCALE,
color: rgb(color.r, color.g, color.b),
opacity: color.opacity,
});
}
}
}
await new Promise((r) => setTimeout(r, 0));
continue;
}
if (mode === 'split') { if (mode === 'split') {
const refPage = leftPdjsPage || rightPdjsPage; const refPage = leftPdjsPage || rightPdjsPage;

View File

@@ -3,7 +3,14 @@ import type { LRUCache } from './lru-cache.ts';
export type CompareViewMode = 'overlay' | 'side-by-side'; export type CompareViewMode = 'overlay' | 'side-by-side';
export type ComparePdfExportMode = 'split' | 'alternating' | 'left' | 'right'; export type ComparePdfExportMode =
| 'split'
| 'alternating'
| 'left'
| 'right'
| 'overlay';
export type CompareOverlayChangeScope = 'all' | 'content-only';
export interface RenderedPage { export interface RenderedPage {
model: ComparePageModel | null; model: ComparePageModel | null;
@@ -201,6 +208,8 @@ export interface CompareState {
pdfDoc2: pdfjsLib.PDFDocumentProxy | null; pdfDoc2: pdfjsLib.PDFDocumentProxy | null;
currentPage: number; currentPage: number;
viewMode: CompareViewMode; viewMode: CompareViewMode;
overlayChangeScope: CompareOverlayChangeScope;
overlayDocumentVisible: boolean;
isSyncScroll: boolean; isSyncScroll: boolean;
currentComparison: ComparePageResult | null; currentComparison: ComparePageResult | null;
activeChangeIndex: number; activeChangeIndex: number;

View File

@@ -35,6 +35,8 @@ const pageState: CompareState = {
pdfDoc2: null, pdfDoc2: null,
currentPage: 1, currentPage: 1,
viewMode: 'side-by-side', viewMode: 'side-by-side',
overlayChangeScope: 'all',
overlayDocumentVisible: true,
isSyncScroll: true, isSyncScroll: true,
currentComparison: null, currentComparison: null,
activeChangeIndex: 0, activeChangeIndex: 0,
@@ -81,41 +83,76 @@ function getRenderContext(): CompareRenderContext {
}; };
} }
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) { function getVisibleChanges(result: ComparePageResult | null) {
if (!result) return []; if (!result) return [];
const filteredByType = return result.changes.filter((change) =>
pageState.activeFilter === 'all' shouldIncludeChange(change, { includeSearch: true })
? result.changes
: result.changes.filter((change) => {
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;
});
const filteredByCategory = filteredByType.filter(
(change) => pageState.categoryFilter[change.category]
); );
const searchQuery = pageState.changeSearchQuery.trim().toLowerCase();
if (!searchQuery) {
return filteredByCategory;
}
return filteredByCategory.filter((change) => {
const searchableText = [
change.description,
change.beforeText,
change.afterText,
]
.join(' ')
.toLowerCase();
return searchableText.includes(searchQuery);
});
} }
function updateFilterButtons() { function updateFilterButtons() {
@@ -131,9 +168,88 @@ function updateFilterButtons() {
const button = getElement<HTMLButtonElement>(id); const button = getElement<HTMLButtonElement>(id);
if (!button) return; if (!button) return;
button.classList.toggle('active', pageState.activeFilter === filter); 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<HTMLButtonElement>('overlay-scope-all');
const contentOnlyButton = getElement<HTMLButtonElement>(
'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<HTMLCanvasElement>('canvas-compare-2');
const panel2 = getElement<HTMLElement>('panel-2');
const opacitySlider = getElement<HTMLInputElement>('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() { function updateSummary() {
const comparison = pageState.currentComparison; const comparison = pageState.currentComparison;
const addedCount = getElement<HTMLElement>('summary-added-count'); const addedCount = getElement<HTMLElement>('summary-added-count');
@@ -182,14 +298,15 @@ function updateCategoryPills(comparison: ComparePageResult | null) {
]; ];
const summary = comparison?.categorySummary; const summary = comparison?.categorySummary;
const effectiveCategoryFilter = getEffectiveCategoryFilter();
for (const key of categoryKeys) { for (const key of categoryKeys) {
const countEl = getElement<HTMLElement>(`category-count-${key}`); const countEl = getElement<HTMLElement>(`category-count-${key}`);
const pill = getElement<HTMLButtonElement>(`category-${key}`); const pill = getElement<HTMLButtonElement>(`category-${key}`);
if (countEl) countEl.textContent = summary ? summary[key].toString() : '0'; if (countEl) countEl.textContent = summary ? summary[key].toString() : '0';
if (pill) { if (pill) {
pill.classList.toggle('active', pageState.categoryFilter[key]); pill.classList.toggle('active', effectiveCategoryFilter[key]);
pill.classList.toggle('disabled', !pageState.categoryFilter[key]); pill.classList.toggle('disabled', !effectiveCategoryFilter[key]);
} }
} }
} }
@@ -349,10 +466,30 @@ function renderChangeList() {
} }
function renderComparisonUI() { function renderComparisonUI() {
updateOverlayScopeButtons();
updateExportMenuForViewMode();
updateFilterButtons(); updateFilterButtons();
renderHighlights(); renderHighlights();
renderChangeList(); renderChangeList();
updateSummary(); updateSummary();
updateOverlayPreviewState();
syncComparePaneHeights();
}
function syncComparePaneHeights() {
const wrapper = getElement<HTMLElement>('compare-viewer-wrapper');
const sidebar = document.querySelector<HTMLElement>('.compare-sidebar');
if (!wrapper || !sidebar) {
return;
}
if (window.innerWidth <= 1023) {
wrapper.style.height = '';
return;
}
wrapper.style.height = `${sidebar.offsetHeight}px`;
} }
async function buildPagePairs() { async function buildPagePairs() {
@@ -528,8 +665,7 @@ function setViewMode(mode: 'overlay' | 'side-by-side') {
btnSide.classList.add('bg-gray-700'); btnSide.classList.add('bg-gray-700');
} }
if (canvas2 && opacitySlider) { if (canvas2 && opacitySlider) {
const panel2 = getElement<HTMLElement>('panel-2'); canvas2.style.transition = 'opacity 150ms ease-in-out';
if (panel2) panel2.style.opacity = opacitySlider.value;
} }
pageState.isSyncScroll = true; pageState.isSyncScroll = true;
} else { } else {
@@ -551,6 +687,11 @@ function setViewMode(mode: 'overlay' | 'side-by-side') {
if (panel2) panel2.style.opacity = '1'; if (panel2) panel2.style.opacity = '1';
} }
updateOverlayScopeButtons();
updateExportMenuForViewMode();
updateOverlayPreviewState();
syncComparePaneHeights();
const p1 = getElement<HTMLElement>('panel-1'); const p1 = getElement<HTMLElement>('panel-1');
const p2 = getElement<HTMLElement>('panel-2'); const p2 = getElement<HTMLElement>('panel-2');
if (mode === 'overlay' && p1 && p2) { if (mode === 'overlay' && p1 && p2) {
@@ -715,28 +856,17 @@ document.addEventListener('DOMContentLoaded', function () {
'opacity-slider' 'opacity-slider'
) as HTMLInputElement; ) as HTMLInputElement;
// Track flicker state
let flickerVisible = true;
if (flickerBtn) { if (flickerBtn) {
flickerBtn.addEventListener('click', function () { flickerBtn.addEventListener('click', function () {
flickerVisible = !flickerVisible; pageState.overlayDocumentVisible = !pageState.overlayDocumentVisible;
const p2 = getElement<HTMLElement>('panel-2'); updateOverlayPreviewState();
if (p2) {
p2.style.transition = 'opacity 150ms ease-in-out';
p2.style.opacity = flickerVisible ? opacitySlider?.value || '0.5' : '0';
}
}); });
} }
if (opacitySlider) { if (opacitySlider) {
opacitySlider.addEventListener('input', function () { opacitySlider.addEventListener('input', function () {
flickerVisible = true; pageState.overlayDocumentVisible = true;
const p2 = getElement<HTMLElement>('panel-2'); updateOverlayPreviewState();
if (p2) {
p2.style.transition = '';
p2.style.opacity = opacitySlider.value;
}
}); });
} }
@@ -752,7 +882,12 @@ document.addEventListener('DOMContentLoaded', function () {
); );
const exportDropdownMenu = getElement<HTMLDivElement>('export-dropdown-menu'); const exportDropdownMenu = getElement<HTMLDivElement>('export-dropdown-menu');
const ocrToggle = getElement<HTMLInputElement>('ocr-toggle'); const ocrToggle = getElement<HTMLInputElement>('ocr-toggle');
const overlayOcrToggle = getElement<HTMLInputElement>('overlay-ocr-toggle');
const searchInput = getElement<HTMLInputElement>('compare-search-input'); const searchInput = getElement<HTMLInputElement>('compare-search-input');
const overlayAllBtn = getElement<HTMLButtonElement>('overlay-scope-all');
const overlayContentOnlyBtn = getElement<HTMLButtonElement>(
'overlay-scope-content-only'
);
const filterButtons: Array<{ id: string; filter: CompareFilterType }> = [ const filterButtons: Array<{ id: string; filter: CompareFilterType }> = [
{ id: 'filter-modified', filter: 'modified' }, { id: 'filter-modified', filter: 'modified' },
@@ -874,6 +1009,13 @@ document.addEventListener('DOMContentLoaded', function () {
const button = getElement<HTMLButtonElement>(id); const button = getElement<HTMLButtonElement>(id);
if (!button) return; if (!button) return;
button.addEventListener('click', function () { button.addEventListener('click', function () {
if (
filter === 'style-changed' &&
pageState.viewMode === 'overlay' &&
pageState.overlayChangeScope === 'content-only'
) {
return;
}
if (pageState.activeFilter === filter) { if (pageState.activeFilter === filter) {
pageState.activeFilter = 'all'; pageState.activeFilter = 'all';
} else { } else {
@@ -897,6 +1039,13 @@ document.addEventListener('DOMContentLoaded', function () {
const pill = getElement<HTMLButtonElement>(`category-${key}`); const pill = getElement<HTMLButtonElement>(`category-${key}`);
if (pill) { if (pill) {
pill.addEventListener('click', function () { pill.addEventListener('click', function () {
if (
key === 'formatting' &&
pageState.viewMode === 'overlay' &&
pageState.overlayChangeScope === 'content-only'
) {
return;
}
pageState.categoryFilter[key] = !pageState.categoryFilter[key]; pageState.categoryFilter[key] = !pageState.categoryFilter[key];
pageState.activeChangeIndex = 0; pageState.activeChangeIndex = 0;
renderComparisonUI(); renderComparisonUI();
@@ -904,21 +1053,59 @@ document.addEventListener('DOMContentLoaded', function () {
} }
} }
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) { if (ocrToggle) {
ocrToggle.checked = pageState.useOcr; ocrToggle.checked = pageState.useOcr;
ocrToggle.addEventListener('change', async function () { ocrToggle.addEventListener('change', async function () {
try { await handleOcrToggleChange(ocrToggle.checked);
pageState.useOcr = ocrToggle.checked; });
caches.pageModelCache.clear(); }
caches.comparisonCache.clear();
caches.comparisonResultsCache.clear(); if (overlayOcrToggle) {
if (pageState.pdfDoc1 && pageState.pdfDoc2) { overlayOcrToggle.checked = pageState.useOcr;
await renderBothPages(); overlayOcrToggle.addEventListener('change', async function () {
} await handleOcrToggleChange(overlayOcrToggle.checked);
} catch (e) {
console.error('OCR toggle failed:', e);
hideLoader();
}
}); });
} }
@@ -933,15 +1120,25 @@ document.addEventListener('DOMContentLoaded', function () {
let resizeFrame = 0; let resizeFrame = 0;
window.addEventListener('resize', function () { window.addEventListener('resize', function () {
if (!pageState.pdfDoc1 || !pageState.pdfDoc2) { if (!pageState.pdfDoc1 || !pageState.pdfDoc2) {
syncComparePaneHeights();
return; return;
} }
window.cancelAnimationFrame(resizeFrame); window.cancelAnimationFrame(resizeFrame);
resizeFrame = window.requestAnimationFrame(function () { resizeFrame = window.requestAnimationFrame(function () {
syncComparePaneHeights();
renderBothPages().catch(console.error); renderBothPages().catch(console.error);
}); });
}); });
const sidebar = document.querySelector<HTMLElement>('.compare-sidebar');
if (sidebar && typeof ResizeObserver !== 'undefined') {
const resizeObserver = new ResizeObserver(function () {
syncComparePaneHeights();
});
resizeObserver.observe(sidebar);
}
if (exportDropdownBtn && exportDropdownMenu) { if (exportDropdownBtn && exportDropdownMenu) {
exportDropdownBtn.addEventListener('click', function (e) { exportDropdownBtn.addEventListener('click', function (e) {
e.stopPropagation(); e.stopPropagation();
@@ -971,6 +1168,24 @@ document.addEventListener('DOMContentLoaded', function () {
pageState.pagePairs, pageState.pagePairs,
function (message, percent) { function (message, percent) {
showLoader(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:
pageState.viewMode === 'overlay'
? (change) =>
shouldIncludeChange(change, { includeSearch: false })
: undefined,
} }
); );
} catch (e) { } catch (e) {
@@ -985,5 +1200,8 @@ document.addEventListener('DOMContentLoaded', function () {
createIcons({ icons }); createIcons({ icons });
updateFilterButtons(); updateFilterButtons();
updateOverlayScopeButtons();
updateExportMenuForViewMode();
syncComparePaneHeights();
setViewMode(pageState.viewMode); setViewMode(pageState.viewMode);
}); });

View File

@@ -1,6 +1,7 @@
export type { export type {
CompareState, CompareState,
ComparePdfExportMode, ComparePdfExportMode,
CompareOverlayChangeScope,
RenderedPage, RenderedPage,
ComparisonPageLoad, ComparisonPageLoad,
DiffFocusRegion, DiffFocusRegion,

View File

@@ -72,14 +72,19 @@
<style> <style>
.compare-viewer-wrapper.overlay-mode { .compare-viewer-wrapper.overlay-mode {
position: relative; position: relative;
display: flex;
align-items: stretch;
background: #ffffff; background: #ffffff;
overflow: hidden; overflow: hidden;
min-height: 0;
padding: 1.5rem; padding: 1.5rem;
} }
.compare-viewer-wrapper.overlay-mode #panel-1 { .compare-viewer-wrapper.overlay-mode #panel-1 {
flex: 1 1 auto;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 0;
overflow: auto; overflow: auto;
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: none; -ms-overflow-style: none;
@@ -92,6 +97,7 @@
.compare-viewer-wrapper.overlay-mode #panel-2 { .compare-viewer-wrapper.overlay-mode #panel-2 {
position: absolute; position: absolute;
inset: 1.5rem; inset: 1.5rem;
min-height: 0;
overflow: hidden; overflow: hidden;
pointer-events: none; pointer-events: none;
background: transparent; background: transparent;
@@ -128,6 +134,12 @@
gap: 1rem; gap: 1rem;
grid-template-columns: minmax(0, 1fr) 20rem; grid-template-columns: minmax(0, 1fr) 20rem;
align-items: stretch; align-items: stretch;
min-height: clamp(32rem, 72vh, 56rem);
}
.compare-viewer-wrapper {
height: 100%;
min-height: 0;
} }
.compare-viewer-wrapper.side-by-side-mode #panel-1, .compare-viewer-wrapper.side-by-side-mode #panel-1,
@@ -248,6 +260,7 @@
border: 1px solid rgba(51, 65, 85, 0.5); border: 1px solid rgba(51, 65, 85, 0.5);
border-radius: 0.75rem; border-radius: 0.75rem;
overflow: hidden; overflow: hidden;
height: 100%;
min-height: 0; min-height: 0;
} }
@@ -495,6 +508,7 @@
@media (max-width: 1023px) { @media (max-width: 1023px) {
.compare-workspace { .compare-workspace {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
min-height: 0;
} }
.compare-sidebar { .compare-sidebar {
@@ -502,6 +516,11 @@
max-height: 24rem; max-height: 24rem;
} }
.compare-viewer-wrapper {
height: auto;
min-height: 24rem;
}
.compare-viewer-wrapper.side-by-side-mode { .compare-viewer-wrapper.side-by-side-mode {
gap: 1rem; gap: 1rem;
padding: 1rem; padding: 1rem;
@@ -708,12 +727,37 @@
<div class="flex-1"></div> <div class="flex-1"></div>
<div id="overlay-controls" class="hidden flex items-center gap-2"> <div id="overlay-controls" class="hidden flex items-center gap-2">
<div class="bg-gray-700 p-0.5 rounded flex gap-0.5">
<button
id="overlay-scope-all"
class="btn bg-indigo-600 px-2.5 py-1 rounded text-xs font-semibold"
>
All changes
</button>
<button
id="overlay-scope-content-only"
class="btn px-2.5 py-1 rounded text-xs font-semibold bg-gray-700"
>
Content only
</button>
</div>
<button <button
id="flicker-btn" id="flicker-btn"
class="btn bg-gray-700 hover:bg-gray-600 px-2.5 py-1 rounded text-xs font-semibold" class="btn bg-gray-700 hover:bg-gray-600 px-2.5 py-1 rounded text-xs font-semibold"
> >
Flicker Flicker
</button> </button>
<label
class="flex items-center gap-1.5 text-xs text-gray-300 cursor-pointer"
>
<input
type="checkbox"
id="overlay-ocr-toggle"
checked
class="w-3.5 h-3.5 rounded text-indigo-600 bg-gray-700 border-gray-600"
/>
OCR
</label>
<input <input
type="range" type="range"
id="opacity-slider" id="opacity-slider"
@@ -769,30 +813,37 @@
> >
Export as PDF Export as PDF
</div> </div>
<button
data-export-mode="overlay"
class="export-menu-item export-menu-item-overlay w-full px-3 py-2 text-sm text-left text-gray-200 hover:bg-gray-700 flex items-center gap-2 hidden"
>
<i data-lucide="layers" class="w-4 h-4 text-gray-400"></i>
Overlay view
</button>
<button <button
data-export-mode="split" data-export-mode="split"
class="export-menu-item w-full px-3 py-2 text-sm text-left text-gray-200 hover:bg-gray-700 flex items-center gap-2" class="export-menu-item export-menu-item-side w-full px-3 py-2 text-sm text-left text-gray-200 hover:bg-gray-700 flex items-center gap-2"
> >
<i data-lucide="columns-2" class="w-4 h-4 text-gray-400"></i> <i data-lucide="columns-2" class="w-4 h-4 text-gray-400"></i>
Split view Split view
</button> </button>
<button <button
data-export-mode="alternating" data-export-mode="alternating"
class="export-menu-item w-full px-3 py-2 text-sm text-left text-gray-200 hover:bg-gray-700 flex items-center gap-2" class="export-menu-item export-menu-item-side w-full px-3 py-2 text-sm text-left text-gray-200 hover:bg-gray-700 flex items-center gap-2"
> >
<i data-lucide="layers" class="w-4 h-4 text-gray-400"></i> <i data-lucide="layers" class="w-4 h-4 text-gray-400"></i>
Alternating Alternating
</button> </button>
<button <button
data-export-mode="left" data-export-mode="left"
class="export-menu-item w-full px-3 py-2 text-sm text-left text-gray-200 hover:bg-gray-700 flex items-center gap-2" class="export-menu-item export-menu-item-side w-full px-3 py-2 text-sm text-left text-gray-200 hover:bg-gray-700 flex items-center gap-2"
> >
<i data-lucide="columns-2" class="w-4 h-4 text-gray-400"></i> <i data-lucide="columns-2" class="w-4 h-4 text-gray-400"></i>
Left Document Left Document
</button> </button>
<button <button
data-export-mode="right" data-export-mode="right"
class="export-menu-item w-full px-3 py-2 text-sm text-left text-gray-200 hover:bg-gray-700 flex items-center gap-2" class="export-menu-item export-menu-item-side w-full px-3 py-2 text-sm text-left text-gray-200 hover:bg-gray-700 flex items-center gap-2"
> >
<i data-lucide="columns-2" class="w-4 h-4 text-gray-400"></i> <i data-lucide="columns-2" class="w-4 h-4 text-gray-400"></i>
Right Document Right Document