mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Feature/v2/compare tool (#4751)
# Description of Changes - Addition of the compare tool - --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
parent
f22f697edc
commit
a5e2b54274
@@ -0,0 +1,566 @@
|
||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
import { appendWord as sharedAppendWord } from '@app/utils/textDiff';
|
||||
import { PARAGRAPH_SENTINEL } from '@app/types/compare';
|
||||
import type { StirlingFile } from '@app/types/fileContext';
|
||||
import type { PDFPageProxy, TextContent, TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||
import type {
|
||||
CompareChange,
|
||||
CompareDiffToken,
|
||||
CompareResultData,
|
||||
TokenBoundingBox,
|
||||
CompareParagraph,
|
||||
} from '@app/types/compare';
|
||||
|
||||
export interface TokenMetadata {
|
||||
page: number;
|
||||
paragraph: number;
|
||||
bbox: TokenBoundingBox | null;
|
||||
}
|
||||
|
||||
export interface ExtractedContent {
|
||||
tokens: string[];
|
||||
metadata: TokenMetadata[];
|
||||
pageSizes: { width: number; height: number }[];
|
||||
paragraphs: CompareParagraph[];
|
||||
}
|
||||
|
||||
const measurementCanvas = typeof document !== 'undefined' ? document.createElement('canvas') : null;
|
||||
const measurementContext = measurementCanvas ? measurementCanvas.getContext('2d') : null;
|
||||
const textMeasurementCache: Map<string, number> | null = measurementContext ? new Map() : null;
|
||||
let lastMeasurementFont = '';
|
||||
|
||||
const DEFAULT_CHAR_WIDTH = 1;
|
||||
const DEFAULT_SPACE_WIDTH = 0.33;
|
||||
|
||||
export const measureTextWidth = (fontSpec: string, text: string): number => {
|
||||
if (!measurementContext) {
|
||||
if (!text) return 0;
|
||||
if (text === ' ') return DEFAULT_SPACE_WIDTH;
|
||||
return text.length * DEFAULT_CHAR_WIDTH;
|
||||
}
|
||||
|
||||
if (lastMeasurementFont !== fontSpec) {
|
||||
measurementContext.font = fontSpec;
|
||||
lastMeasurementFont = fontSpec;
|
||||
}
|
||||
|
||||
const key = `${fontSpec}|${text}`;
|
||||
const cached = textMeasurementCache?.get(key);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const width = measurementContext.measureText(text).width || 0;
|
||||
textMeasurementCache?.set(key, width);
|
||||
return width;
|
||||
};
|
||||
|
||||
export const appendWord = (existing: string, word: string) => {
|
||||
if (!existing) {
|
||||
return sharedAppendWord('', word);
|
||||
}
|
||||
return sharedAppendWord(existing, word);
|
||||
};
|
||||
|
||||
export const aggregateTotals = (tokens: CompareDiffToken[]) => {
|
||||
return tokens.reduce(
|
||||
(totals, token) => {
|
||||
if (token.text === '\uE000PARA') { // PARAGRAPH_SENTINEL safeguard if serialized
|
||||
return totals;
|
||||
}
|
||||
switch (token.type) {
|
||||
case 'added':
|
||||
totals.added += 1;
|
||||
break;
|
||||
case 'removed':
|
||||
totals.removed += 1;
|
||||
break;
|
||||
default:
|
||||
totals.unchanged += 1;
|
||||
}
|
||||
return totals;
|
||||
},
|
||||
{ added: 0, removed: 0, unchanged: 0 }
|
||||
);
|
||||
};
|
||||
|
||||
export const buildChanges = (
|
||||
tokens: CompareDiffToken[],
|
||||
baseMetadata: TokenMetadata[],
|
||||
comparisonMetadata: TokenMetadata[]
|
||||
): CompareChange[] => {
|
||||
const changes: CompareChange[] = [];
|
||||
let baseIndex = 0;
|
||||
let comparisonIndex = 0;
|
||||
let current: CompareChange | null = null;
|
||||
let currentBaseParagraph: number | null = null;
|
||||
let currentComparisonParagraph: number | null = null;
|
||||
|
||||
const ensureCurrent = (): CompareChange => {
|
||||
if (!current) {
|
||||
current = {
|
||||
id: `change-${changes.length}`,
|
||||
base: null,
|
||||
comparison: null,
|
||||
};
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (current) {
|
||||
if (current.base) {
|
||||
current.base.text = current.base.text.trim();
|
||||
}
|
||||
if (current.comparison) {
|
||||
current.comparison.text = current.comparison.text.trim();
|
||||
}
|
||||
|
||||
if ((current.base?.text && current.base.text.length > 0) || (current.comparison?.text && current.comparison.text.length > 0)) {
|
||||
changes.push(current);
|
||||
}
|
||||
}
|
||||
current = null;
|
||||
currentBaseParagraph = null;
|
||||
currentComparisonParagraph = null;
|
||||
};
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.type === 'removed') {
|
||||
const meta = baseMetadata[baseIndex] ?? null;
|
||||
const active = ensureCurrent();
|
||||
const paragraph = meta?.paragraph ?? null;
|
||||
if (!active.base) {
|
||||
active.base = {
|
||||
text: token.text,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: meta?.paragraph ?? null,
|
||||
};
|
||||
currentBaseParagraph = paragraph;
|
||||
} else {
|
||||
if (
|
||||
paragraph !== null &&
|
||||
currentBaseParagraph !== null &&
|
||||
paragraph !== currentBaseParagraph &&
|
||||
active.base.text.trim().length > 0
|
||||
) {
|
||||
flush();
|
||||
const next = ensureCurrent();
|
||||
next.base = {
|
||||
text: token.text,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: paragraph,
|
||||
};
|
||||
} else {
|
||||
active.base.text = appendWord(active.base.text, token.text);
|
||||
}
|
||||
if (meta && active.base.page === null) {
|
||||
active.base.page = meta.page;
|
||||
}
|
||||
if (meta && active.base.paragraph === null) {
|
||||
active.base.paragraph = meta.paragraph;
|
||||
}
|
||||
if (paragraph !== null) {
|
||||
currentBaseParagraph = paragraph;
|
||||
}
|
||||
}
|
||||
if (baseIndex < baseMetadata.length) {
|
||||
baseIndex += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.type === 'added') {
|
||||
const meta = comparisonMetadata[comparisonIndex] ?? null;
|
||||
const active = ensureCurrent();
|
||||
const paragraph = meta?.paragraph ?? null;
|
||||
if (!active.comparison) {
|
||||
active.comparison = {
|
||||
text: token.text,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: meta?.paragraph ?? null,
|
||||
};
|
||||
currentComparisonParagraph = paragraph;
|
||||
} else {
|
||||
if (
|
||||
paragraph !== null &&
|
||||
currentComparisonParagraph !== null &&
|
||||
paragraph !== currentComparisonParagraph &&
|
||||
active.comparison.text.trim().length > 0
|
||||
) {
|
||||
flush();
|
||||
const next = ensureCurrent();
|
||||
next.comparison = {
|
||||
text: token.text,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: paragraph,
|
||||
};
|
||||
} else {
|
||||
active.comparison.text = appendWord(active.comparison.text, token.text);
|
||||
}
|
||||
if (meta && active.comparison.page === null) {
|
||||
active.comparison.page = meta.page;
|
||||
}
|
||||
if (meta && active.comparison.paragraph === null) {
|
||||
active.comparison.paragraph = meta.paragraph;
|
||||
}
|
||||
if (paragraph !== null) {
|
||||
currentComparisonParagraph = paragraph;
|
||||
}
|
||||
}
|
||||
if (comparisonIndex < comparisonMetadata.length) {
|
||||
comparisonIndex += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// unchanged token
|
||||
flush();
|
||||
if (baseIndex < baseMetadata.length) {
|
||||
baseIndex += 1;
|
||||
}
|
||||
if (comparisonIndex < comparisonMetadata.length) {
|
||||
comparisonIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
|
||||
return changes;
|
||||
};
|
||||
|
||||
export const createSummaryFile = (result: CompareResultData): File => {
|
||||
const exportPayload = {
|
||||
generatedAt: new Date(result.totals.processedAt).toISOString(),
|
||||
base: {
|
||||
name: result.base.fileName,
|
||||
totalWords: result.base.wordCount,
|
||||
},
|
||||
comparison: {
|
||||
name: result.comparison.fileName,
|
||||
totalWords: result.comparison.wordCount,
|
||||
},
|
||||
totals: {
|
||||
added: result.totals.added,
|
||||
removed: result.totals.removed,
|
||||
unchanged: result.totals.unchanged,
|
||||
durationMs: result.totals.durationMs,
|
||||
},
|
||||
changes: result.changes.map((change) => ({
|
||||
base: change.base,
|
||||
comparison: change.comparison,
|
||||
})),
|
||||
warnings: result.warnings,
|
||||
};
|
||||
|
||||
const filename = `compare-summary-${new Date(result.totals.processedAt).toISOString().replace(/[:.]/g, '-')}.json`;
|
||||
return new File([JSON.stringify(exportPayload, null, 2)], filename, { type: 'application/json' });
|
||||
};
|
||||
|
||||
export const clamp = (value: number): number => Math.min(1, Math.max(0, value));
|
||||
|
||||
export const getWorkerErrorCode = (value: unknown): 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' | undefined => {
|
||||
if (typeof value === 'object' && value !== null && 'code' in value) {
|
||||
const potentialCode = (value as { code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' }).code;
|
||||
return potentialCode;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Produce a filtered view of tokens/metadata that excludes paragraph sentinel markers,
|
||||
// returning a mapping to original indices for potential future use.
|
||||
export const filterTokensForDiff = (
|
||||
tokens: string[],
|
||||
metadata: TokenMetadata[],
|
||||
): { tokens: string[]; metadata: TokenMetadata[]; filteredToOriginal: number[] } => {
|
||||
const outTokens: string[] = [];
|
||||
const outMeta: TokenMetadata[] = [];
|
||||
const map: number[] = [];
|
||||
for (let i = 0; i < tokens.length; i += 1) {
|
||||
const t = tokens[i];
|
||||
const isPara = t === PARAGRAPH_SENTINEL || t.startsWith('\uE000') || t.includes('PARA');
|
||||
if (!isPara) {
|
||||
outTokens.push(t);
|
||||
if (metadata[i]) outMeta.push(metadata[i]);
|
||||
map.push(i);
|
||||
}
|
||||
}
|
||||
return { tokens: outTokens, metadata: outMeta, filteredToOriginal: map };
|
||||
};
|
||||
|
||||
export const extractContentFromPdf = async (file: StirlingFile): Promise<ExtractedContent> => {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const tokens: string[] = [];
|
||||
const metadata: TokenMetadata[] = [];
|
||||
const pageSizes: { width: number; height: number }[] = [];
|
||||
const paragraphs: CompareParagraph[] = [];
|
||||
for (let pageIndex = 1; pageIndex <= pdfDoc.numPages; pageIndex += 1) {
|
||||
const page: PDFPageProxy = await pdfDoc.getPage(pageIndex);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const content: TextContent = await page.getTextContent({
|
||||
disableCombineTextItems: true,
|
||||
} as Parameters<PDFPageProxy['getTextContent']>[0]);
|
||||
const styles: Record<string, { fontFamily?: string; ascent?: number; descent?: number }> = content.styles ?? {};
|
||||
|
||||
let paragraphIndex = 1;
|
||||
let paragraphBuffer = '';
|
||||
let prevItem: TextItem | null = null;
|
||||
|
||||
pageSizes.push({ width: viewport.width, height: viewport.height });
|
||||
|
||||
const normalizeToken = (s: string) =>
|
||||
s
|
||||
.normalize('NFKC')
|
||||
.replace(/[\u00AD\u200B-\u200F\u202A-\u202E]/g, '')
|
||||
.replace(/[“”]/g, '"')
|
||||
.replace(/[‘’]/g, "'")
|
||||
.replace(/[–—]/g, '-')
|
||||
.replace(/\u00A0/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const isParagraphBreak = (curr: TextItem, prev: TextItem | null) => {
|
||||
const hasHardBreak = 'hasEOL' in curr && (curr as TextItem).hasEOL;
|
||||
if (hasHardBreak) return true;
|
||||
if (!prev) return false;
|
||||
const prevY = prev.transform[5];
|
||||
const currY = curr.transform[5];
|
||||
const dy = Math.abs(currY - prevY);
|
||||
const currX = curr.transform[4];
|
||||
const prevX = prev.transform[4];
|
||||
const approxLine = Math.max(10, Math.abs((curr as any).height ?? 0) * 0.9);
|
||||
const looksLikeParagraph = dy > approxLine * 1.8;
|
||||
const likelySoftWrap = currX < prevX && dy < approxLine * 0.6;
|
||||
return looksLikeParagraph && !likelySoftWrap;
|
||||
};
|
||||
|
||||
const adjustBoundingBox = (left: number, top: number, width: number, height: number): TokenBoundingBox | null => {
|
||||
if (width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 0.004;
|
||||
const MIN_HORIZONTAL_PAD = 0.0012;
|
||||
const HORIZONTAL_PAD_RATIO = 0.12;
|
||||
const MIN_VERTICAL_PAD = 0.0008;
|
||||
const VERTICAL_PAD_RATIO = 0.18;
|
||||
|
||||
const horizontalPad = Math.max(width * HORIZONTAL_PAD_RATIO, MIN_HORIZONTAL_PAD);
|
||||
const verticalPad = Math.max(height * VERTICAL_PAD_RATIO, MIN_VERTICAL_PAD);
|
||||
|
||||
let expandedLeft = left - horizontalPad;
|
||||
let expandedRight = left + width + horizontalPad;
|
||||
let expandedTop = top - verticalPad;
|
||||
let expandedBottom = top + height + verticalPad;
|
||||
|
||||
if (expandedRight - expandedLeft < MIN_WIDTH) {
|
||||
const deficit = MIN_WIDTH - (expandedRight - expandedLeft);
|
||||
expandedLeft -= deficit / 2;
|
||||
expandedRight += deficit / 2;
|
||||
}
|
||||
|
||||
expandedLeft = clamp(expandedLeft);
|
||||
expandedRight = clamp(expandedRight);
|
||||
expandedTop = clamp(expandedTop);
|
||||
expandedBottom = clamp(expandedBottom);
|
||||
|
||||
if (expandedRight <= expandedLeft || expandedBottom <= expandedTop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
left: expandedLeft,
|
||||
top: expandedTop,
|
||||
width: expandedRight - expandedLeft,
|
||||
height: expandedBottom - expandedTop,
|
||||
};
|
||||
};
|
||||
|
||||
for (const item of content.items as TextItem[]) {
|
||||
if (!item?.str) {
|
||||
prevItem = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawText = item.str;
|
||||
const totalLen = Math.max(rawText.length, 1);
|
||||
const textStyle = item.fontName ? styles[item.fontName] : undefined;
|
||||
const fontFamily = textStyle?.fontFamily ?? 'sans-serif';
|
||||
const fontScale = Math.max(0.5, Math.hypot(item.transform[0], item.transform[1]) || 0);
|
||||
const fontSpec = `${fontScale}px ${fontFamily}`;
|
||||
|
||||
const weights: number[] = new Array(totalLen);
|
||||
let runningText = '';
|
||||
let previousAdvance = 0;
|
||||
for (let i = 0; i < totalLen; i += 1) {
|
||||
runningText += rawText[i];
|
||||
const advance = measureTextWidth(fontSpec, runningText);
|
||||
let width = advance - previousAdvance;
|
||||
if (!Number.isFinite(width) || width <= 0) {
|
||||
width = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
|
||||
}
|
||||
weights[i] = width;
|
||||
previousAdvance = advance;
|
||||
}
|
||||
if (!Number.isFinite(previousAdvance) || previousAdvance <= 0) {
|
||||
for (let i = 0; i < totalLen; i += 1) {
|
||||
weights[i] = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
|
||||
}
|
||||
}
|
||||
const prefix: number[] = new Array(totalLen + 1);
|
||||
prefix[0] = 0;
|
||||
for (let i = 0; i < totalLen; i += 1) prefix[i + 1] = prefix[i] + weights[i];
|
||||
const totalWeight = prefix[totalLen] || 1;
|
||||
|
||||
const rawX = item.transform[4];
|
||||
const rawY = item.transform[5];
|
||||
const transformed = [
|
||||
viewport.convertToViewportPoint(rawX, rawY),
|
||||
viewport.convertToViewportPoint(rawX + item.width, rawY),
|
||||
viewport.convertToViewportPoint(rawX, rawY + item.height),
|
||||
viewport.convertToViewportPoint(rawX + item.width, rawY + item.height),
|
||||
];
|
||||
const xs = transformed.map(([px]) => px);
|
||||
const ys = transformed.map(([, py]) => py);
|
||||
const left = Math.min(...xs);
|
||||
const right = Math.max(...xs);
|
||||
const top = Math.min(...ys);
|
||||
const bottom = Math.max(...ys);
|
||||
|
||||
if (!Number.isFinite(left) || !Number.isFinite(right) || !Number.isFinite(top) || !Number.isFinite(bottom)) {
|
||||
prevItem = item;
|
||||
continue;
|
||||
}
|
||||
|
||||
const [baselineStart, baselineEnd, verticalEnd] = transformed;
|
||||
const baselineVector: [number, number] = [
|
||||
baselineEnd[0] - baselineStart[0],
|
||||
baselineEnd[1] - baselineStart[1],
|
||||
];
|
||||
const verticalVector: [number, number] = [
|
||||
verticalEnd[0] - baselineStart[0],
|
||||
verticalEnd[1] - baselineStart[1],
|
||||
];
|
||||
const baselineMagnitude = Math.hypot(baselineVector[0], baselineVector[1]);
|
||||
const verticalMagnitude = Math.hypot(verticalVector[0], verticalVector[1]);
|
||||
const hasOrientationVectors = baselineMagnitude > 1e-6 && verticalMagnitude > 1e-6;
|
||||
|
||||
const font = item.fontName ? styles[item.fontName] : undefined;
|
||||
const ascent = typeof font?.ascent === 'number' ? Math.max(0.7, Math.min(1.1, font.ascent)) : 0.9;
|
||||
const descent = typeof font?.descent === 'number' ? Math.max(0.0, Math.min(0.5, Math.abs(font.descent))) : 0.2;
|
||||
const verticalScale = Math.min(1, Math.max(0.75, ascent + descent));
|
||||
|
||||
const wordRegex = /[A-Za-z0-9]+|[^\sA-Za-z0-9]/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = wordRegex.exec(rawText)) !== null) {
|
||||
const wordRaw = match[0];
|
||||
const normalizedWord = normalizeToken(wordRaw);
|
||||
if (!normalizedWord) {
|
||||
continue;
|
||||
}
|
||||
const startIndex = match.index;
|
||||
const endIndex = startIndex + wordRaw.length;
|
||||
|
||||
const relStart = prefix[startIndex] / totalWeight;
|
||||
const relEnd = prefix[endIndex] / totalWeight;
|
||||
|
||||
let wordLeftAbs: number;
|
||||
let wordRightAbs: number;
|
||||
let wordTopAbs: number;
|
||||
let wordBottomAbs: number;
|
||||
|
||||
if (hasOrientationVectors) {
|
||||
const segStart: [number, number] = [
|
||||
baselineStart[0] + baselineVector[0] * relStart,
|
||||
baselineStart[1] + baselineVector[1] * relStart,
|
||||
];
|
||||
const segEnd: [number, number] = [
|
||||
baselineStart[0] + baselineVector[0] * relEnd,
|
||||
baselineStart[1] + baselineVector[1] * relEnd,
|
||||
];
|
||||
const cornerPoints: Array<[number, number]> = [
|
||||
segStart,
|
||||
[segStart[0] + verticalVector[0], segStart[1] + verticalVector[1]],
|
||||
[segEnd[0] + verticalVector[0], segEnd[1] + verticalVector[1]],
|
||||
segEnd,
|
||||
];
|
||||
const cornerXs = cornerPoints.map(([px]) => px);
|
||||
const cornerYs = cornerPoints.map(([, py]) => py);
|
||||
wordLeftAbs = Math.min(...cornerXs);
|
||||
wordRightAbs = Math.max(...cornerXs);
|
||||
wordTopAbs = Math.min(...cornerYs);
|
||||
wordBottomAbs = Math.max(...cornerYs);
|
||||
} else {
|
||||
const segLeftAbs = left + (right - left) * relStart;
|
||||
const segRightAbs = left + (right - left) * relEnd;
|
||||
wordLeftAbs = Math.min(segLeftAbs, segRightAbs);
|
||||
wordRightAbs = Math.max(segLeftAbs, segRightAbs);
|
||||
wordTopAbs = top;
|
||||
wordBottomAbs = bottom;
|
||||
}
|
||||
|
||||
const wordLeft = clamp(wordLeftAbs / viewport.width);
|
||||
const wordRight = clamp(wordRightAbs / viewport.width);
|
||||
const wordTop = clamp(wordTopAbs / viewport.height);
|
||||
const wordBottom = clamp(wordBottomAbs / viewport.height);
|
||||
const wordWidth = Math.max(0, wordRight - wordLeft);
|
||||
let wordHeight = Math.max(0, wordBottom - wordTop);
|
||||
|
||||
if (wordHeight > 0 && verticalScale < 1) {
|
||||
const midY = (wordTop + wordBottom) / 2;
|
||||
const shrunkHeight = Math.max(0, wordHeight * verticalScale);
|
||||
const half = shrunkHeight / 2;
|
||||
const newTop = clamp(midY - half);
|
||||
const newBottom = clamp(midY + half);
|
||||
wordHeight = Math.max(0, newBottom - newTop);
|
||||
const bbox = adjustBoundingBox(wordLeft, newTop, wordWidth, wordHeight);
|
||||
tokens.push(normalizedWord);
|
||||
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox });
|
||||
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bbox = adjustBoundingBox(wordLeft, wordTop, wordWidth, wordHeight);
|
||||
|
||||
tokens.push(normalizedWord);
|
||||
metadata.push({
|
||||
page: pageIndex,
|
||||
paragraph: paragraphIndex,
|
||||
bbox,
|
||||
});
|
||||
|
||||
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
|
||||
}
|
||||
|
||||
if (isParagraphBreak(item as TextItem, prevItem)) {
|
||||
if (paragraphBuffer.trim().length > 0) {
|
||||
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
|
||||
paragraphBuffer = '';
|
||||
}
|
||||
tokens.push('\uE000PARA');
|
||||
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
|
||||
paragraphIndex += 1;
|
||||
}
|
||||
prevItem = item as TextItem;
|
||||
}
|
||||
|
||||
if (paragraphBuffer.trim().length > 0) {
|
||||
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
|
||||
paragraphBuffer = '';
|
||||
tokens.push('\uE000PARA');
|
||||
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
|
||||
}
|
||||
}
|
||||
return { tokens, metadata, pageSizes, paragraphs };
|
||||
} finally {
|
||||
pdfWorkerManager.destroyDocument(pdfDoc);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ADDITION_HIGHLIGHT,
|
||||
CompareDiffToken,
|
||||
CompareFilteredTokenInfo,
|
||||
CompareResultData,
|
||||
CompareWorkerRequest,
|
||||
CompareWorkerResponse,
|
||||
CompareWorkerWarnings,
|
||||
REMOVAL_HIGHLIGHT,
|
||||
} from '@app/types/compare';
|
||||
import { CompareParameters } from '@app/hooks/tools/compare/useCompareParameters';
|
||||
import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
|
||||
import type { StirlingFile } from '@app/types/fileContext';
|
||||
import { useFileContext } from '@app/contexts/file/fileHooks';
|
||||
import {
|
||||
aggregateTotals,
|
||||
buildChanges,
|
||||
createSummaryFile,
|
||||
extractContentFromPdf,
|
||||
getWorkerErrorCode,
|
||||
filterTokensForDiff,
|
||||
} from '@app/hooks/tools/compare/operationUtils';
|
||||
import { alert, dismissToast } from '@app/components/toast';
|
||||
import type { ToastLocation } from '@app/components/toast/types';
|
||||
import CompareWorkerCtor from '@app/workers/compareWorker?worker';
|
||||
const LONG_RUNNING_PAGE_THRESHOLD = 2000;
|
||||
|
||||
export interface CompareOperationHook extends ToolOperationHook<CompareParameters> {
|
||||
result: CompareResultData | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// extractContentFromPdf moved to utils
|
||||
|
||||
export const useCompareOperation = (): CompareOperationHook => {
|
||||
const { t } = useTranslation();
|
||||
const { selectors } = useFileContext();
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const previousUrl = useRef<string | null>(null);
|
||||
const activeRunIdRef = useRef(0);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
type OperationStatus = 'idle' | 'extracting' | 'processing' | 'complete' | 'cancelled' | 'error';
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [statusState, setStatusState] = useState<OperationStatus>('idle');
|
||||
const [statusDetailMs, setStatusDetailMs] = useState<number | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [downloadFilename, setDownloadFilename] = useState('');
|
||||
const [result, setResult] = useState<CompareResultData | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const longRunningToastIdRef = useRef<string | null>(null);
|
||||
const dissimilarityToastIdRef = useRef<string | null>(null);
|
||||
const dissimilarityToastShownRef = useRef<boolean>(false);
|
||||
|
||||
const ensureWorker = useCallback(() => {
|
||||
if (!workerRef.current) {
|
||||
workerRef.current = new CompareWorkerCtor();
|
||||
}
|
||||
return workerRef.current;
|
||||
}, []);
|
||||
|
||||
const cleanupDownloadUrl = useCallback(() => {
|
||||
if (previousUrl.current) {
|
||||
URL.revokeObjectURL(previousUrl.current);
|
||||
previousUrl.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetResults = useCallback(() => {
|
||||
setResult(null);
|
||||
setWarnings([]);
|
||||
setFiles([]);
|
||||
cleanupDownloadUrl();
|
||||
setDownloadUrl(null);
|
||||
setDownloadFilename('');
|
||||
setStatusState('idle');
|
||||
setStatusDetailMs(null);
|
||||
setErrorMessage(null);
|
||||
}, [cleanupDownloadUrl]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setErrorMessage(null);
|
||||
}, []);
|
||||
|
||||
const runCompareWorker = useCallback(
|
||||
async (baseTokens: string[], comparisonTokens: string[], warningMessages: CompareWorkerWarnings, onChunk?: (chunk: CompareDiffToken[]) => void) => {
|
||||
const worker = ensureWorker();
|
||||
|
||||
return await new Promise<{
|
||||
tokens: CompareDiffToken[];
|
||||
stats: { baseWordCount: number; comparisonWordCount: number; durationMs: number };
|
||||
warnings: string[];
|
||||
}>((resolve, reject) => {
|
||||
const collectedWarnings: string[] = [];
|
||||
const collectedTokens: CompareDiffToken[] = [];
|
||||
|
||||
const handleMessage = (event: MessageEvent<CompareWorkerResponse>) => {
|
||||
if (cancelledRef.current) {
|
||||
cleanup();
|
||||
reject(Object.assign(new Error('Operation cancelled'), { code: 'CANCELLED' as const }));
|
||||
return;
|
||||
}
|
||||
const message = event.data;
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'chunk': {
|
||||
if (message.tokens.length > 0) {
|
||||
collectedTokens.push(...message.tokens);
|
||||
onChunk?.(message.tokens);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'success':
|
||||
cleanup();
|
||||
if (longRunningToastIdRef.current) {
|
||||
dismissToast(longRunningToastIdRef.current);
|
||||
longRunningToastIdRef.current = null;
|
||||
}
|
||||
resolve({
|
||||
tokens: collectedTokens,
|
||||
stats: message.stats,
|
||||
warnings: collectedWarnings,
|
||||
});
|
||||
break;
|
||||
case 'warning':
|
||||
collectedWarnings.push(message.message);
|
||||
break;
|
||||
case 'error': {
|
||||
cleanup();
|
||||
if (longRunningToastIdRef.current) {
|
||||
dismissToast(longRunningToastIdRef.current);
|
||||
longRunningToastIdRef.current = null;
|
||||
}
|
||||
const error: Error & { code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' } = new Error(message.message);
|
||||
error.code = message.code;
|
||||
reject(error);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (event: ErrorEvent) => {
|
||||
cleanup();
|
||||
if (cancelledRef.current) {
|
||||
reject(Object.assign(new Error('Operation cancelled'), { code: 'CANCELLED' as const }));
|
||||
} else {
|
||||
reject(event.error ?? new Error(event.message));
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
worker.removeEventListener('message', handleMessage as EventListener);
|
||||
worker.removeEventListener('error', handleError as EventListener);
|
||||
};
|
||||
|
||||
worker.addEventListener('message', handleMessage as EventListener);
|
||||
worker.addEventListener('error', handleError as EventListener);
|
||||
|
||||
const request: CompareWorkerRequest = {
|
||||
type: 'compare',
|
||||
payload: {
|
||||
baseTokens,
|
||||
comparisonTokens,
|
||||
warnings: warningMessages,
|
||||
// Static worker settings to support large documents
|
||||
settings: {
|
||||
batchSize: 5000,
|
||||
complexThreshold: 120000,
|
||||
maxWordThreshold: 200000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
worker.postMessage(request);
|
||||
});
|
||||
},
|
||||
[ensureWorker]
|
||||
);
|
||||
|
||||
const executeOperation = useCallback(
|
||||
async (params: CompareParameters, selectedFiles: StirlingFile[]) => {
|
||||
// start new run
|
||||
const runId = ++activeRunIdRef.current;
|
||||
cancelledRef.current = false;
|
||||
if (!params.baseFileId || !params.comparisonFileId) {
|
||||
setErrorMessage(t('compare.error.selectRequired', 'Select the original and edited document.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const baseFile = selectedFiles.find((file) => file.fileId === params.baseFileId)
|
||||
?? selectors.getFile(params.baseFileId);
|
||||
const comparisonFile = selectedFiles.find((file) => file.fileId === params.comparisonFileId)
|
||||
?? selectors.getFile(params.comparisonFileId);
|
||||
|
||||
if (!baseFile || !comparisonFile) {
|
||||
setErrorMessage(t('compare.error.filesMissing', 'Unable to locate the selected files. Please re-select them.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setStatusState('extracting');
|
||||
setStatusDetailMs(null);
|
||||
setErrorMessage(null);
|
||||
setWarnings([]);
|
||||
setResult(null);
|
||||
setFiles([]);
|
||||
cleanupDownloadUrl();
|
||||
setDownloadUrl(null);
|
||||
setDownloadFilename('');
|
||||
|
||||
const warningMessages: CompareWorkerWarnings = {
|
||||
// No accuracy warning any more
|
||||
tooLargeMessage: t(
|
||||
'compare.large.file.message',
|
||||
'These documents are very large; comparison may take several minutes. Please keep this tab open.'
|
||||
),
|
||||
emptyTextMessage: t(
|
||||
'compare.no.text.message',
|
||||
'One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison.'
|
||||
),
|
||||
tooDissimilarMessage: t(
|
||||
'compare.too.dissimilar.message',
|
||||
'These documents appear highly dissimilar. Comparison was stopped to save time.'
|
||||
),
|
||||
};
|
||||
|
||||
const operationStart = performance.now();
|
||||
|
||||
try {
|
||||
const [baseContent, comparisonContent] = await Promise.all([
|
||||
extractContentFromPdf(baseFile),
|
||||
extractContentFromPdf(comparisonFile),
|
||||
]);
|
||||
|
||||
if (cancelledRef.current || activeRunIdRef.current !== runId) return;
|
||||
|
||||
if (baseContent.tokens.length === 0 || comparisonContent.tokens.length === 0) {
|
||||
throw Object.assign(new Error(warningMessages.emptyTextMessage), { code: 'EMPTY_TEXT' });
|
||||
}
|
||||
|
||||
setStatusState('processing');
|
||||
|
||||
// Filter out paragraph sentinels before diffing to avoid large false-positive runs
|
||||
const baseFiltered = filterTokensForDiff(baseContent.tokens, baseContent.metadata);
|
||||
const comparisonFiltered = filterTokensForDiff(comparisonContent.tokens, comparisonContent.metadata);
|
||||
|
||||
const combinedPageCount =
|
||||
(baseContent.pageSizes?.length ?? 0) + (comparisonContent.pageSizes?.length ?? 0);
|
||||
|
||||
if (
|
||||
combinedPageCount >= LONG_RUNNING_PAGE_THRESHOLD &&
|
||||
!longRunningToastIdRef.current
|
||||
) {
|
||||
const toastId = alert({
|
||||
alertType: 'neutral',
|
||||
title: t('compare.longJob.title', 'Large comparison in progress'),
|
||||
body: t(
|
||||
'compare.longJob.body',
|
||||
'These PDFs together exceed 2,000 pages. Processing can take several minutes.'
|
||||
),
|
||||
location: 'bottom-right' as ToastLocation,
|
||||
isPersistentPopup: true,
|
||||
expandable: false,
|
||||
});
|
||||
longRunningToastIdRef.current = toastId || null;
|
||||
}
|
||||
|
||||
// Heuristic: surface an early warning toast when we observe a very high ratio of differences
|
||||
const EARLY_TOAST_MIN_TOKENS = 15000; // wait for some signal before warning
|
||||
const EARLY_TOAST_DIFF_RATIO = 0.8; // 80% added/removed vs unchanged
|
||||
let observedAddedRemoved = 0;
|
||||
let observedUnchanged = 0;
|
||||
|
||||
const handleEarlyDissimilarity = () => {
|
||||
if (dissimilarityToastShownRef.current || dissimilarityToastIdRef.current) return;
|
||||
const toastId = alert({
|
||||
alertType: 'warning',
|
||||
title: t('compare.earlyDissimilarity.title', 'These PDFs look highly different'),
|
||||
body: t(
|
||||
'compare.earlyDissimilarity.body',
|
||||
"We're seeing very few similarities so far. You can stop the comparison if these aren't related documents."
|
||||
),
|
||||
location: 'bottom-right' as ToastLocation,
|
||||
isPersistentPopup: true,
|
||||
expandable: false,
|
||||
buttonText: t('compare.earlyDissimilarity.stopButton', 'Stop comparison'),
|
||||
buttonCallback: () => {
|
||||
try { cancelOperation(); } catch {
|
||||
console.error('Failed to cancel operation');
|
||||
}
|
||||
try { window.dispatchEvent(new CustomEvent('compare:clear-selected')); } catch {
|
||||
console.error('Failed to dispatch clear selected event');
|
||||
}
|
||||
if (dissimilarityToastIdRef.current) {
|
||||
dismissToast(dissimilarityToastIdRef.current);
|
||||
dissimilarityToastIdRef.current = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
dissimilarityToastIdRef.current = toastId || null;
|
||||
dissimilarityToastShownRef.current = true;
|
||||
};
|
||||
|
||||
const { tokens, stats, warnings: workerWarnings } = await runCompareWorker(
|
||||
baseFiltered.tokens,
|
||||
comparisonFiltered.tokens,
|
||||
warningMessages,
|
||||
(chunk) => {
|
||||
// Incremental ratio tracking for early warning
|
||||
for (const tok of chunk) {
|
||||
if (tok.type === 'unchanged') observedUnchanged += 1;
|
||||
else observedAddedRemoved += 1;
|
||||
}
|
||||
const seen = observedAddedRemoved + observedUnchanged;
|
||||
if (
|
||||
!dissimilarityToastShownRef.current &&
|
||||
seen >= EARLY_TOAST_MIN_TOKENS &&
|
||||
observedAddedRemoved / Math.max(1, seen) >= EARLY_TOAST_DIFF_RATIO
|
||||
) {
|
||||
handleEarlyDissimilarity();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (cancelledRef.current || activeRunIdRef.current !== runId) return;
|
||||
|
||||
const baseHasHighlight = new Array<boolean>(baseFiltered.tokens.length).fill(false);
|
||||
const comparisonHasHighlight = new Array<boolean>(comparisonFiltered.tokens.length).fill(false);
|
||||
|
||||
let baseTokenPointer = 0;
|
||||
let comparisonTokenPointer = 0;
|
||||
for (const diffToken of tokens) {
|
||||
if (diffToken.type === 'removed') {
|
||||
if (baseTokenPointer < baseHasHighlight.length) {
|
||||
baseHasHighlight[baseTokenPointer] = true;
|
||||
}
|
||||
baseTokenPointer += 1;
|
||||
} else if (diffToken.type === 'added') {
|
||||
if (comparisonTokenPointer < comparisonHasHighlight.length) {
|
||||
comparisonHasHighlight[comparisonTokenPointer] = true;
|
||||
}
|
||||
comparisonTokenPointer += 1;
|
||||
} else {
|
||||
if (baseTokenPointer < baseHasHighlight.length) {
|
||||
baseTokenPointer += 1;
|
||||
}
|
||||
if (comparisonTokenPointer < comparisonHasHighlight.length) {
|
||||
comparisonTokenPointer += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buildFilteredTokenData = (
|
||||
tokensList: typeof baseFiltered.tokens,
|
||||
metadataList: typeof baseFiltered.metadata,
|
||||
highlightFlags: boolean[]
|
||||
): CompareFilteredTokenInfo[] =>
|
||||
tokensList.map((token, index) => {
|
||||
const meta = metadataList[index];
|
||||
return {
|
||||
token,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: meta?.paragraph ?? null,
|
||||
bbox: meta?.bbox ?? null,
|
||||
hasHighlight: highlightFlags[index] ?? false,
|
||||
metaIndex: index,
|
||||
};
|
||||
});
|
||||
|
||||
const totals = aggregateTotals(tokens);
|
||||
const processedAt = Date.now();
|
||||
|
||||
const baseMetadata = baseFiltered.metadata;
|
||||
const comparisonMetadata = comparisonFiltered.metadata;
|
||||
|
||||
const changes = buildChanges(tokens, baseMetadata, comparisonMetadata);
|
||||
|
||||
const comparisonResult: CompareResultData = {
|
||||
base: {
|
||||
fileId: baseFile.fileId,
|
||||
fileName: baseFile.name,
|
||||
highlightColor: REMOVAL_HIGHLIGHT,
|
||||
wordCount: stats.baseWordCount,
|
||||
pageSizes: baseContent.pageSizes,
|
||||
},
|
||||
comparison: {
|
||||
fileId: comparisonFile.fileId,
|
||||
fileName: comparisonFile.name,
|
||||
highlightColor: ADDITION_HIGHLIGHT,
|
||||
wordCount: stats.comparisonWordCount,
|
||||
pageSizes: comparisonContent.pageSizes,
|
||||
},
|
||||
totals: {
|
||||
...totals,
|
||||
durationMs: stats.durationMs,
|
||||
processedAt,
|
||||
},
|
||||
tokens,
|
||||
tokenMetadata: {
|
||||
base: baseMetadata,
|
||||
comparison: comparisonMetadata,
|
||||
},
|
||||
filteredTokenData: {
|
||||
base: buildFilteredTokenData(baseFiltered.tokens, baseFiltered.metadata, baseHasHighlight),
|
||||
comparison: buildFilteredTokenData(
|
||||
comparisonFiltered.tokens,
|
||||
comparisonFiltered.metadata,
|
||||
comparisonHasHighlight
|
||||
),
|
||||
},
|
||||
sourceTokens: {
|
||||
base: baseContent.tokens,
|
||||
comparison: comparisonContent.tokens,
|
||||
},
|
||||
changes,
|
||||
warnings: workerWarnings,
|
||||
baseParagraphs: baseContent.paragraphs,
|
||||
comparisonParagraphs: comparisonContent.paragraphs,
|
||||
};
|
||||
|
||||
setResult(comparisonResult);
|
||||
setWarnings(workerWarnings);
|
||||
|
||||
const summaryFile = createSummaryFile(comparisonResult);
|
||||
setFiles([summaryFile]);
|
||||
|
||||
cleanupDownloadUrl();
|
||||
const blobUrl = URL.createObjectURL(summaryFile);
|
||||
previousUrl.current = blobUrl;
|
||||
setDownloadUrl(blobUrl);
|
||||
setDownloadFilename(summaryFile.name);
|
||||
|
||||
setStatusState('complete');
|
||||
} catch (error: unknown) {
|
||||
console.error('[compare] operation failed', error);
|
||||
const errorCode = getWorkerErrorCode(error);
|
||||
if (errorCode === 'EMPTY_TEXT') {
|
||||
setErrorMessage(warningMessages.emptyTextMessage ?? t('compare.error.generic', 'Unable to compare these files.'));
|
||||
} else {
|
||||
const fallbackMessage = t('compare.error.generic', 'Unable to compare these files.');
|
||||
if (error instanceof Error && error.message) {
|
||||
setErrorMessage(error.message);
|
||||
} else if (typeof error === 'string' && error.trim().length > 0) {
|
||||
setErrorMessage(error);
|
||||
} else {
|
||||
setErrorMessage(fallbackMessage);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
const duration = performance.now() - operationStart;
|
||||
setStatusDetailMs(Math.round(duration));
|
||||
setIsLoading(false);
|
||||
if (longRunningToastIdRef.current) {
|
||||
dismissToast(longRunningToastIdRef.current);
|
||||
longRunningToastIdRef.current = null;
|
||||
}
|
||||
if (dissimilarityToastIdRef.current) {
|
||||
dismissToast(dissimilarityToastIdRef.current);
|
||||
dissimilarityToastIdRef.current = null;
|
||||
}
|
||||
dissimilarityToastShownRef.current = false;
|
||||
}
|
||||
},
|
||||
[cleanupDownloadUrl, runCompareWorker, selectors, t]
|
||||
);
|
||||
|
||||
const cancelOperation = useCallback(() => {
|
||||
if (!isLoading) return;
|
||||
cancelledRef.current = true;
|
||||
setIsLoading(false);
|
||||
setStatusState('cancelled');
|
||||
if (workerRef.current) {
|
||||
try {
|
||||
workerRef.current.terminate();
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
workerRef.current = null;
|
||||
}
|
||||
if (longRunningToastIdRef.current) {
|
||||
dismissToast(longRunningToastIdRef.current);
|
||||
longRunningToastIdRef.current = null;
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
const undoOperation = useCallback(async () => {
|
||||
resetResults();
|
||||
}, [resetResults]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupDownloadUrl();
|
||||
if (workerRef.current) {
|
||||
workerRef.current.terminate();
|
||||
workerRef.current = null;
|
||||
}
|
||||
if (longRunningToastIdRef.current) {
|
||||
dismissToast(longRunningToastIdRef.current);
|
||||
longRunningToastIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [cleanupDownloadUrl]);
|
||||
|
||||
const status = useMemo(() => {
|
||||
const label =
|
||||
statusState === 'idle' ? ''
|
||||
: statusState === 'extracting' ? t('compare.status.extracting', 'Extracting text...')
|
||||
: statusState === 'processing' ? t('compare.status.processing', 'Analyzing differences...')
|
||||
: statusState === 'complete' ? t('compare.status.complete', 'Comparison ready')
|
||||
: statusState === 'cancelled' ? t('operationCancelled', 'Operation cancelled')
|
||||
: '';
|
||||
if (label && statusDetailMs != null) return `${label} (${statusDetailMs} ms)`;
|
||||
return label;
|
||||
}, [statusState, statusDetailMs, t]);
|
||||
|
||||
return useMemo<CompareOperationHook>(
|
||||
() => ({
|
||||
files,
|
||||
thumbnails: [],
|
||||
isGeneratingThumbnails: false,
|
||||
downloadUrl,
|
||||
downloadFilename,
|
||||
isLoading,
|
||||
status,
|
||||
errorMessage,
|
||||
progress: null,
|
||||
executeOperation,
|
||||
resetResults,
|
||||
clearError,
|
||||
cancelOperation,
|
||||
undoOperation,
|
||||
result,
|
||||
warnings,
|
||||
}),
|
||||
[
|
||||
cancelOperation,
|
||||
clearError,
|
||||
downloadFilename,
|
||||
downloadUrl,
|
||||
errorMessage,
|
||||
executeOperation,
|
||||
files,
|
||||
isLoading,
|
||||
resetResults,
|
||||
result,
|
||||
status,
|
||||
undoOperation,
|
||||
warnings,
|
||||
]
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BaseParametersHook, useBaseParameters } from '@app/hooks/tools/shared/useBaseParameters';
|
||||
import type { FileId } from '@app/types/file';
|
||||
|
||||
export interface CompareParameters {
|
||||
baseFileId: FileId | null;
|
||||
comparisonFileId: FileId | null;
|
||||
}
|
||||
|
||||
export const defaultParameters: CompareParameters = {
|
||||
baseFileId: null,
|
||||
comparisonFileId: null,
|
||||
};
|
||||
|
||||
export type CompareParametersHook = BaseParametersHook<CompareParameters>;
|
||||
|
||||
export const useCompareParameters = (): CompareParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'compare',
|
||||
validateFn: (params) =>
|
||||
Boolean(params.baseFileId && params.comparisonFileId && params.baseFileId !== params.comparisonFileId),
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user