diff --git a/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx b/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx index 115e5330f..4c35439a1 100644 --- a/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx +++ b/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx @@ -165,7 +165,7 @@ const CompareDocumentPane = ({ offsetPixels: OFFSET_PIXELS, }); - const { highlightOffset, baseWidth, baseHeight, containerWidth, containerHeight, innerScale } = metrics; + const { highlightOffset, containerWidth, containerHeight, innerScale } = metrics; // Compute clamped pan for current zoom so content always touches edges when in bounds const storedPan = pagePanRef.current.get(page.pageNumber) || { x: 0, y: 0 }; diff --git a/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx b/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx index 5790b54ef..ae24dee5b 100644 --- a/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx +++ b/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx @@ -45,8 +45,8 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { const baseFile = getFileFromSelection(data?.baseLocalFile, baseFileId, selectors); const comparisonFile = getFileFromSelection(data?.comparisonLocalFile, comparisonFileId, selectors); - const baseStub = getStubFromSelection(baseFileId, selectors) as any; - const comparisonStub = getStubFromSelection(comparisonFileId, selectors) as any; + const baseStub = getStubFromSelection(baseFileId, selectors); + const comparisonStub = getStubFromSelection(comparisonFileId, selectors); const processedAt = result?.totals.processedAt ?? null; diff --git a/frontend/src/core/components/tools/compare/compare.ts b/frontend/src/core/components/tools/compare/compare.ts index 1cb0e9590..e8b9fcbc9 100644 --- a/frontend/src/core/components/tools/compare/compare.ts +++ b/frontend/src/core/components/tools/compare/compare.ts @@ -1,6 +1,6 @@ import type { TokenBoundingBox, WordHighlightEntry } from '@app/types/compare'; import type { FileId } from '@app/types/file'; -import type { StirlingFile } from '@app/types/fileContext'; +import type { StirlingFile, StirlingFileStub } from '@app/types/fileContext'; import type { PagePreview } from '@app/types/compare'; /** Convert hex color (#rrggbb) to rgba() string with alpha; falls back to input if invalid. */ @@ -175,8 +175,8 @@ export const getFileFromSelection = ( export const getStubFromSelection = ( fileId: FileId | null, - selectors: { getStirlingFileStub: (id: FileId) => unknown } -): unknown | null => { + selectors: { getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined } +): StirlingFileStub | null => { if (!fileId) return null; const stub = selectors.getStirlingFileStub(fileId); return stub ?? null; diff --git a/frontend/src/core/components/tools/compare/hooks/useComparePagePreviews.ts b/frontend/src/core/components/tools/compare/hooks/useComparePagePreviews.ts index 2bb4cf260..19e4c9363 100644 --- a/frontend/src/core/components/tools/compare/hooks/useComparePagePreviews.ts +++ b/frontend/src/core/components/tools/compare/hooks/useComparePagePreviews.ts @@ -160,7 +160,7 @@ export const useComparePagePreviews = ({ }; } - const key = `${(file as any).name || 'file'}:${(file as any).size || 0}:${cacheKey ?? 'none'}`; + const key = `${file.name || 'file'}:${file.size || 0}:${cacheKey ?? 'none'}`; const refreshVersion = Symbol(key); latestVersionMap.set(key, refreshVersion); const entry = getOrCreateEntry(key); diff --git a/frontend/src/core/components/tools/compare/hooks/useComparePanZoom.ts b/frontend/src/core/components/tools/compare/hooks/useComparePanZoom.ts index e86592cb6..df5a275b9 100644 --- a/frontend/src/core/components/tools/compare/hooks/useComparePanZoom.ts +++ b/frontend/src/core/components/tools/compare/hooks/useComparePanZoom.ts @@ -440,6 +440,63 @@ export const useComparePanZoom = ({ return () => { cleanupBase(); cleanupComp(); }; }, []); + // Helpers for clearer pan-edge overscroll behavior + const getVerticalOverflow = useCallback((rawY: number, maxY: number): number => { + if (rawY < 0) return rawY; // negative -> scroll up + if (rawY > maxY) return rawY - maxY; // positive -> scroll down + return 0; + }, []); + + const normalizeApplyCandidate = useCallback((overflowY: number): number => { + const DEADZONE = 32; // pixels + if (overflowY < -DEADZONE) return overflowY + DEADZONE; + if (overflowY > DEADZONE) return overflowY - DEADZONE; + return 0; + }, []); + + const applyIncrementalScroll = useCallback((container: HTMLDivElement, isBase: boolean, applyCandidate: number) => { + const STEP = 48; // pixels per incremental scroll + const key = isBase ? 'base' : 'comparison'; + const deltaSinceLast = applyCandidate - edgeOverscrollRef.current[key]; + const magnitude = Math.abs(deltaSinceLast); + if (magnitude < STEP) return; + + const stepDelta = Math.sign(deltaSinceLast) * Math.floor(magnitude / STEP) * STEP; + edgeOverscrollRef.current[key] += stepDelta; + + const prevTop = container.scrollTop; + const nextTop = Math.max(0, Math.min(container.scrollHeight - container.clientHeight, prevTop + stepDelta)); + if (nextTop === prevTop) return; + + container.scrollTop = nextTop; + if (isScrollLinked) { + const sourceIsBase = isBase; + const target = isBase ? comparisonScrollRef.current : baseScrollRef.current; + if (target) { + const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight); + const mappedTop = mapScrollTopBetweenPanes(nextTop, sourceIsBase); + const deltaPx = sourceIsBase + ? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp + : scrollLinkAnchorsRef.current.deltaPixelsCompToBase; + const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx)); + target.scrollTop = desiredTop; + } + } + }, [isScrollLinked, mapScrollTopBetweenPanes]); + + const handlePanEdgeOverscroll = useCallback((rawY: number, boundsMaxY: number, isBase: boolean) => { + const container = isBase ? baseScrollRef.current : comparisonScrollRef.current; + if (!container) return; + const overflowY = getVerticalOverflow(rawY, boundsMaxY); + const applyCandidate = normalizeApplyCandidate(overflowY); + if (applyCandidate !== 0) { + applyIncrementalScroll(container, isBase, applyCandidate); + } else { + // Reset accumulator when back within deadzone + edgeOverscrollRef.current[isBase ? 'base' : 'comparison'] = 0; + } + }, [applyIncrementalScroll, getVerticalOverflow, normalizeApplyCandidate]); + const beginPan = useCallback( (pane: Pane, event: ReactMouseEvent) => { if (!isPanMode) return; @@ -489,47 +546,7 @@ export const useComparePanZoom = ({ }; // On vertical overscroll beyond pan bounds, scroll the page (with deadzone + incremental steps) - const container = isBase ? baseScrollRef.current : comparisonScrollRef.current; - if (container) { - const DEADZONE = 32; // pixels - const STEP = 48; // pixels per incremental scroll - let overflowY = 0; - if (rawY < 0) overflowY = rawY; // negative -> scroll up - else if (rawY > bounds.maxY) overflowY = rawY - bounds.maxY; // positive -> scroll down - let applyCandidate = 0; - if (overflowY < -DEADZONE) applyCandidate = overflowY + DEADZONE; - else if (overflowY > DEADZONE) applyCandidate = overflowY - DEADZONE; - if (applyCandidate !== 0) { - const key = isBase ? 'base' : 'comparison'; - const deltaSinceLast = applyCandidate - edgeOverscrollRef.current[key]; - const magnitude = Math.abs(deltaSinceLast); - if (magnitude >= STEP) { - const stepDelta = Math.sign(deltaSinceLast) * Math.floor(magnitude / STEP) * STEP; - edgeOverscrollRef.current[key] += stepDelta; - const prevTop = container.scrollTop; - const nextTop = Math.max(0, Math.min(container.scrollHeight - container.clientHeight, prevTop + stepDelta)); - if (nextTop !== prevTop) { - container.scrollTop = nextTop; - if (isScrollLinked) { - const sourceIsBase = isBase; - const target = isBase ? comparisonScrollRef.current : baseScrollRef.current; - if (target) { - const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight); - const mappedTop = mapScrollTopBetweenPanes(nextTop, sourceIsBase); - const deltaPx = sourceIsBase - ? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp - : scrollLinkAnchorsRef.current.deltaPixelsCompToBase; - const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx)); - target.scrollTop = desiredTop; - } - } - } - } - } else { - // Reset accumulator when back within deadzone - edgeOverscrollRef.current[isBase ? 'base' : 'comparison'] = 0; - } - } + handlePanEdgeOverscroll(rawY, bounds.maxY, isBase); if (isBase) { setBasePan(desired); @@ -729,45 +746,7 @@ export const useComparePanZoom = ({ y: Math.max(0, Math.min(bounds.maxY, rawY)), }; - const container = isBase ? baseScrollRef.current : comparisonScrollRef.current; - if (container) { - const DEADZONE = 32; - const STEP = 48; - let overflowY = 0; - if (rawY < 0) overflowY = rawY; else if (rawY > bounds.maxY) overflowY = rawY - bounds.maxY; - let applyCandidate = 0; - if (overflowY < -DEADZONE) applyCandidate = overflowY + DEADZONE; - else if (overflowY > DEADZONE) applyCandidate = overflowY - DEADZONE; - if (applyCandidate !== 0) { - const key = isBase ? 'base' : 'comparison'; - const deltaSinceLast = applyCandidate - edgeOverscrollRef.current[key]; - const magnitude = Math.abs(deltaSinceLast); - if (magnitude >= STEP) { - const stepDelta = Math.sign(deltaSinceLast) * Math.floor(magnitude / STEP) * STEP; - edgeOverscrollRef.current[key] += stepDelta; - const prevTop = container.scrollTop; - const nextTop = Math.max(0, Math.min(container.scrollHeight - container.clientHeight, prevTop + stepDelta)); - if (nextTop !== prevTop) { - container.scrollTop = nextTop; - if (isScrollLinked) { - const sourceIsBase = isBase; - const target = isBase ? comparisonScrollRef.current : baseScrollRef.current; - if (target) { - const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight); - const mappedTop = mapScrollTopBetweenPanes(nextTop, sourceIsBase); - const deltaPx = sourceIsBase - ? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp - : scrollLinkAnchorsRef.current.deltaPixelsCompToBase; - const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx)); - target.scrollTop = desiredTop; - } - } - } - } - } else { - edgeOverscrollRef.current[isBase ? 'base' : 'comparison'] = 0; - } - } + handlePanEdgeOverscroll(rawY, bounds.maxY, isBase); if (isBase) { setBasePan(desired); } else { diff --git a/frontend/src/core/hooks/tools/compare/useCompareOperation.ts b/frontend/src/core/hooks/tools/compare/useCompareOperation.ts index 79bf20cce..3e2e26ff3 100644 --- a/frontend/src/core/hooks/tools/compare/useCompareOperation.ts +++ b/frontend/src/core/hooks/tools/compare/useCompareOperation.ts @@ -36,7 +36,7 @@ export interface CompareOperationHook extends ToolOperationHook { const { t } = useTranslation(); - const { selectors, actions: fileActions } = useFileContext(); + const { selectors } = useFileContext(); const workerRef = useRef(null); const previousUrl = useRef(null); const activeRunIdRef = useRef(0); @@ -59,7 +59,7 @@ export const useCompareOperation = (): CompareOperationHook => { const ensureWorker = useCallback(() => { if (!workerRef.current) { workerRef.current = new Worker( - new URL('../../../../workers/compareWorker.ts', import.meta.url), + new URL('/@app/workers/compareWorker.ts', import.meta.url), { type: 'module' } ); } @@ -297,8 +297,12 @@ export const useCompareOperation = (): CompareOperationHook => { expandable: false, buttonText: t('compare.earlyDissimilarity.stopButton', 'Stop comparison'), buttonCallback: () => { - try { cancelOperation(); } catch {} - try { window.dispatchEvent(new CustomEvent('compare:clear-selected')); } catch {} + 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; diff --git a/frontend/src/core/hooks/useProgressivePagePreviews.ts b/frontend/src/core/hooks/useProgressivePagePreviews.ts index c2b3a600c..ef62af4fa 100644 --- a/frontend/src/core/hooks/useProgressivePagePreviews.ts +++ b/frontend/src/core/hooks/useProgressivePagePreviews.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { pdfWorkerManager } from '@app/services/pdfWorkerManager'; +import type { PDFDocumentProxy } from 'pdfjs-dist/legacy/build/pdf.mjs'; import { PagePreview } from '@app/types/compare'; const DISPLAY_SCALE = 1; @@ -36,11 +37,11 @@ export const useProgressivePagePreviews = ({ loadingPages: new Set(), }); - const pdfRef = useRef(null); + const pdfRef = useRef(null); const abortControllerRef = useRef(null); const renderPageBatch = useCallback(async ( - pdf: any, + pdf: PDFDocumentProxy, pageNumbers: number[], signal: AbortSignal ): Promise => { @@ -114,7 +115,9 @@ export const useProgressivePagePreviews = ({ })); try { - const previews = await renderPageBatch(pdfRef.current, pagesToLoad, signal); + const pdfDoc = pdfRef.current; + if (!pdfDoc) return; + const previews = await renderPageBatch(pdfDoc, pagesToLoad, signal); if (!signal.aborted) { setState(prev => { diff --git a/frontend/src/core/tools/Compare.tsx b/frontend/src/core/tools/Compare.tsx index 9e6a50c01..6b321d80c 100644 --- a/frontend/src/core/tools/Compare.tsx +++ b/frontend/src/core/tools/Compare.tsx @@ -80,7 +80,7 @@ const Compare = (props: BaseToolProps) => { // Use a static label at registration time to avoid re-registering on i18n changes label: 'Compare view', icon: compareIcon, - component: CompareWorkbenchView as any, + component: CompareWorkbenchView, }); return () => { diff --git a/frontend/src/core/tools/ValidateSignature.tsx b/frontend/src/core/tools/ValidateSignature.tsx index 0d9fdec59..30fab68d9 100644 --- a/frontend/src/core/tools/ValidateSignature.tsx +++ b/frontend/src/core/tools/ValidateSignature.tsx @@ -47,7 +47,7 @@ const ValidateSignature = (props: BaseToolProps) => { workbenchId: REPORT_WORKBENCH_ID, label: t('validateSignature.report.shortTitle', 'Signature Report'), icon: reportIcon, - component: ValidateSignatureReportView as any, + component: ValidateSignatureReportView, }); return () => { diff --git a/frontend/src/core/utils/textDiff.ts b/frontend/src/core/utils/textDiff.ts index f1b020847..fcf36a7d0 100644 --- a/frontend/src/core/utils/textDiff.ts +++ b/frontend/src/core/utils/textDiff.ts @@ -1,7 +1,5 @@ // Shared text diff and normalization utilities for compare tool -export const PARAGRAPH_SENTINEL = '\uE000¶'; - export const shouldConcatWithoutSpace = (word: string) => { return /^[.,!?;:)\]}]/.test(word) || word.startsWith("'") || word === "'s"; }; @@ -11,17 +9,6 @@ export const appendWord = (existing: string, word: string) => { if (shouldConcatWithoutSpace(word)) return `${existing}${word}`; return `${existing} ${word}`; }; - -export const normalizeToken = (s: string) => - s - .normalize('NFKC') - .replace(/[\u00AD\u200B-\u200F\u202A-\u202E]/g, '') // soft hyphen + zero width controls - .replace(/[“”]/g, '"') - .replace(/[‘’]/g, "'") - .replace(/[–—]/g, '-') - .replace(/\s+/g, ' ') - .trim(); - export const tokenize = (text: string): string[] => text.split(/\s+/).filter(Boolean); type TokenType = 'unchanged' | 'removed' | 'added'; diff --git a/frontend/src/workers/compareWorker.ts b/frontend/src/workers/compareWorker.ts index 41af4ee77..0e3a323e1 100644 --- a/frontend/src/workers/compareWorker.ts +++ b/frontend/src/workers/compareWorker.ts @@ -264,8 +264,7 @@ const chunkedDiff = ( if (unchangedRatio < runtimeStop.minUnchangedRatio) { // Signal early termination for extreme dissimilarity const err = new Error('EARLY_STOP_TOO_DISSIMILAR'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (err as any).__earlyStop = true; + (err as Error & { __earlyStop?: boolean }).__earlyStop = true; throw err; } } @@ -423,10 +422,9 @@ self.onmessage = (event: MessageEvent) => { }, { maxProcessedTokens: runtimeMaxProcessedTokens, minUnchangedRatio: runtimeMinUnchangedRatio } ); - } catch (err) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const anyErr = err as any; - if (anyErr && (anyErr.__earlyStop || anyErr?.message === 'EARLY_STOP_TOO_DISSIMILAR')) { + } catch (err) { + const error = err as Error & { __earlyStop?: boolean }; + if (error && (error.__earlyStop || error.message === 'EARLY_STOP_TOO_DISSIMILAR')) { const response: CompareWorkerResponse = { type: 'error', message: