feat: enhance PDF comparison with overlay options and filters
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type {
|
export type {
|
||||||
CompareState,
|
CompareState,
|
||||||
ComparePdfExportMode,
|
ComparePdfExportMode,
|
||||||
|
CompareOverlayChangeScope,
|
||||||
RenderedPage,
|
RenderedPage,
|
||||||
ComparisonPageLoad,
|
ComparisonPageLoad,
|
||||||
DiffFocusRegion,
|
DiffFocusRegion,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user