741 lines
23 KiB
TypeScript
741 lines
23 KiB
TypeScript
import { pdfjs } from 'react-pdf';
|
|
import { TextLayer } from 'pdfjs-dist';
|
|
import "core-js/proposals/promise-with-resolvers";
|
|
import type { TTSSentenceAlignment } from '@/types/tts';
|
|
import type { ParsedPdfDocument, ParsedPdfPage } from '@/types/parsed-pdf';
|
|
import type { TTSSegmentLocator } from '@/types/client';
|
|
import { segmentWords } from '@/lib/shared/language';
|
|
import {
|
|
buildAlignmentTokenRanges,
|
|
type HighlightTokenRange,
|
|
} from '@/lib/client/highlight-token-alignment';
|
|
|
|
// Worker coordination for offloading highlight token matching
|
|
interface HighlightTokenMatchRequest {
|
|
id: string;
|
|
type: 'tokenMatch';
|
|
patternTokens: string[];
|
|
tokenTexts: string[];
|
|
}
|
|
|
|
interface HighlightTokenMatchResponse {
|
|
id: string;
|
|
type: 'tokenMatchResult';
|
|
bestStart: number;
|
|
bestEnd: number;
|
|
rating: number;
|
|
lengthDiff: number;
|
|
}
|
|
|
|
let highlightWorker: Worker | null = null;
|
|
|
|
function getHighlightWorker(): Worker | null {
|
|
if (typeof window === 'undefined') return null;
|
|
if (highlightWorker) return highlightWorker;
|
|
|
|
try {
|
|
highlightWorker = new Worker(
|
|
new URL('pdf-highlight-worker.ts', import.meta.url),
|
|
{ type: 'module' }
|
|
);
|
|
return highlightWorker;
|
|
} catch (e) {
|
|
console.error('Failed to initialize PDF highlight worker:', e);
|
|
highlightWorker = null;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function runHighlightTokenMatch(
|
|
patternTokens: string[],
|
|
tokenTexts: string[]
|
|
): Promise<HighlightTokenMatchResponse | null> {
|
|
const worker = getHighlightWorker();
|
|
if (!worker) {
|
|
return Promise.resolve(null);
|
|
}
|
|
|
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
|
|
return new Promise((resolve) => {
|
|
const handleMessage = (event: MessageEvent) => {
|
|
const data = event.data as HighlightTokenMatchResponse;
|
|
if (!data || data.id !== id || data.type !== 'tokenMatchResult') {
|
|
return;
|
|
}
|
|
worker.removeEventListener('message', handleMessage as EventListener);
|
|
resolve(data);
|
|
};
|
|
|
|
worker.addEventListener('message', handleMessage as EventListener);
|
|
|
|
const message: HighlightTokenMatchRequest = {
|
|
id,
|
|
type: 'tokenMatch',
|
|
patternTokens,
|
|
tokenTexts,
|
|
};
|
|
worker.postMessage(message);
|
|
});
|
|
}
|
|
|
|
// Function to detect if we need to use legacy build
|
|
function shouldUseLegacyBuild() {
|
|
try {
|
|
if (typeof window === 'undefined') return false;
|
|
|
|
const ua = window.navigator.userAgent;
|
|
const isSafari = /^((?!chrome|android).)*safari/i.test(ua);
|
|
|
|
if (!isSafari) return false;
|
|
|
|
// Extract Safari version - matches "Version/18" format
|
|
const match = ua.match(/Version\/(\d+)/i);
|
|
if (!match || !match[1]) return true; // If we can't determine version, use legacy to be safe
|
|
|
|
const version = parseInt(match[1]);
|
|
return version < 18; // Use legacy build for Safari versions equal or below 18
|
|
} catch (e) {
|
|
console.error('Error detecting Safari version:', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Function to initialize PDF worker
|
|
function initPDFWorker() {
|
|
try {
|
|
if (typeof window !== 'undefined') {
|
|
const useLegacy = shouldUseLegacyBuild();
|
|
// Use local worker file instead of unpkg
|
|
const workerSrc = useLegacy
|
|
? new URL('pdfjs-dist/legacy/build/pdf.worker.min.mjs', import.meta.url).href
|
|
: new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).href;
|
|
pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
|
|
pdfjs.GlobalWorkerOptions.workerPort = null;
|
|
}
|
|
} catch (e) {
|
|
console.error('Error setting PDF worker:', e);
|
|
}
|
|
}
|
|
|
|
// Initialize the worker
|
|
initPDFWorker();
|
|
|
|
// Patch TextLayer.render to treat cancelled renders as non-errors
|
|
try {
|
|
const textLayerProto = TextLayer?.prototype;
|
|
const originalRender = textLayerProto?.render;
|
|
if (typeof originalRender === 'function') {
|
|
textLayerProto.render = async function patchedRender(...args) {
|
|
const task = originalRender.apply(this, args);
|
|
if (!task || typeof task.then !== 'function') return task;
|
|
return task.catch((error) => {
|
|
if (error && (error.name === 'AbortException' || error.name === 'RenderingCancelledException')) {
|
|
return;
|
|
}
|
|
throw error;
|
|
});
|
|
};
|
|
}
|
|
} catch (e) {
|
|
console.error('Error patching TextLayer.render:', e);
|
|
}
|
|
|
|
type PDFToken = {
|
|
spanIndex: number;
|
|
textNode: Text;
|
|
text: string;
|
|
startOffset: number;
|
|
endOffset: number;
|
|
};
|
|
|
|
let lastSpanNodes: HTMLElement[] = [];
|
|
let lastTokens: PDFToken[] = [];
|
|
let lastSentenceTokenWindow: { start: number; end: number } | null = null;
|
|
let lastSentencePattern: string | null = null;
|
|
let lastSentenceWordToTokenRangeMap: Array<HighlightTokenRange | null> | null = null;
|
|
|
|
function getOrCreateHighlightLayer(span: HTMLElement): {
|
|
layer: HTMLElement;
|
|
pageElement: HTMLElement;
|
|
pageRect: DOMRect;
|
|
} | null {
|
|
const pageElement = span.closest('.react-pdf__Page') as HTMLElement | null;
|
|
if (!pageElement) return null;
|
|
|
|
let layer = pageElement.querySelector('.pdf-highlight-layer') as HTMLElement | null;
|
|
if (!layer) {
|
|
layer = document.createElement('div');
|
|
layer.className = 'pdf-highlight-layer';
|
|
pageElement.appendChild(layer);
|
|
}
|
|
|
|
layer.style.position = 'absolute';
|
|
layer.style.inset = '0';
|
|
layer.style.pointerEvents = 'none';
|
|
layer.style.zIndex = '4';
|
|
layer.style.overflow = 'hidden';
|
|
// Force a compositor layer to avoid Safari occasionally not painting
|
|
// newly-added positioned overlays.
|
|
layer.style.transform = 'translateZ(0)';
|
|
|
|
return { layer, pageElement, pageRect: pageElement.getBoundingClientRect() };
|
|
}
|
|
|
|
// Highlighting functions
|
|
let highlightPatternSeq = 0;
|
|
|
|
type HighlightPatternOptions = {
|
|
parsedDocument?: ParsedPdfDocument | null;
|
|
locator?: TTSSegmentLocator | null;
|
|
useBlockGeometryOnly?: boolean;
|
|
language?: string;
|
|
};
|
|
|
|
function getHighlightLayerForPage(pageElement: HTMLElement): {
|
|
layer: HTMLElement;
|
|
pageRect: DOMRect;
|
|
} {
|
|
let layer = pageElement.querySelector('.pdf-highlight-layer') as HTMLElement | null;
|
|
if (!layer) {
|
|
layer = document.createElement('div');
|
|
layer.className = 'pdf-highlight-layer';
|
|
pageElement.appendChild(layer);
|
|
}
|
|
|
|
layer.style.position = 'absolute';
|
|
layer.style.inset = '0';
|
|
layer.style.pointerEvents = 'none';
|
|
layer.style.zIndex = '4';
|
|
layer.style.overflow = 'hidden';
|
|
layer.style.transform = 'translateZ(0)';
|
|
|
|
return { layer, pageRect: pageElement.getBoundingClientRect() };
|
|
}
|
|
|
|
function findRenderedPageElement(container: HTMLElement, pageNumber: number): HTMLElement | null {
|
|
const direct = container.querySelector(`.react-pdf__Page[data-page-number="${pageNumber}"]`) as HTMLElement | null;
|
|
if (direct) return direct;
|
|
|
|
const pageNodes = Array.from(container.querySelectorAll('.react-pdf__Page')) as HTMLElement[];
|
|
for (const pageNode of pageNodes) {
|
|
const attr = pageNode.getAttribute('data-page-number');
|
|
if (Number(attr) === pageNumber) return pageNode;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function highlightParsedBlockGeometry(
|
|
containerRef: React.RefObject<HTMLDivElement>,
|
|
parsedDocument: ParsedPdfDocument,
|
|
locator: TTSSegmentLocator,
|
|
): boolean {
|
|
if (locator.readerType !== 'pdf') return false;
|
|
if (!locator.blockId) return false;
|
|
const container = containerRef.current;
|
|
if (!container) return false;
|
|
|
|
let targetBlock:
|
|
| ParsedPdfPage['blocks'][number]
|
|
| null = null;
|
|
for (const page of parsedDocument.pages) {
|
|
const found = page.blocks.find((block) => block.id === locator.blockId);
|
|
if (found) {
|
|
targetBlock = found;
|
|
break;
|
|
}
|
|
}
|
|
if (!targetBlock) return false;
|
|
|
|
let firstRect: { top: number; left: number } | null = null;
|
|
let drewAny = false;
|
|
|
|
for (const fragment of targetBlock.fragments) {
|
|
const parsedPage = parsedDocument.pages.find((page) => page.pageNumber === fragment.page);
|
|
if (!parsedPage || parsedPage.width <= 0 || parsedPage.height <= 0) continue;
|
|
|
|
const pageElement = findRenderedPageElement(container, fragment.page);
|
|
if (!pageElement) continue;
|
|
|
|
const { layer, pageRect } = getHighlightLayerForPage(pageElement);
|
|
const [x0, y0, x1, y1] = fragment.bbox;
|
|
const left = (x0 / parsedPage.width) * pageRect.width;
|
|
const top = (y0 / parsedPage.height) * pageRect.height;
|
|
const width = ((x1 - x0) / parsedPage.width) * pageRect.width;
|
|
const height = ((y1 - y0) / parsedPage.height) * pageRect.height;
|
|
|
|
if (!(width > 0 && height > 0)) continue;
|
|
|
|
const highlight = document.createElement('div');
|
|
highlight.className = 'pdf-text-highlight-overlay';
|
|
highlight.style.position = 'absolute';
|
|
highlight.style.backgroundColor = 'grey';
|
|
highlight.style.opacity = '0.4';
|
|
highlight.style.pointerEvents = 'none';
|
|
highlight.style.zIndex = '1';
|
|
highlight.style.left = `${left}px`;
|
|
highlight.style.top = `${top}px`;
|
|
highlight.style.width = `${width}px`;
|
|
highlight.style.height = `${height}px`;
|
|
layer.appendChild(highlight);
|
|
|
|
if (!firstRect) {
|
|
firstRect = { top: pageRect.top + top, left: pageRect.left + left };
|
|
}
|
|
drewAny = true;
|
|
}
|
|
|
|
if (!drewAny || !firstRect) return drewAny;
|
|
|
|
const containerRect = container.getBoundingClientRect();
|
|
const visibleTop = container.scrollTop;
|
|
const visibleBottom = visibleTop + containerRect.height;
|
|
const elementTop = firstRect.top - containerRect.top + container.scrollTop;
|
|
|
|
if (elementTop < visibleTop || elementTop > visibleBottom) {
|
|
container.scrollTo({
|
|
top: elementTop - containerRect.height / 3,
|
|
behavior: 'smooth',
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function resolveParsedBlock(
|
|
parsedDocument: ParsedPdfDocument | null | undefined,
|
|
locator: TTSSegmentLocator | null | undefined,
|
|
): ParsedPdfPage['blocks'][number] | null {
|
|
if (!parsedDocument || !locator || locator.readerType !== 'pdf' || !locator.blockId) {
|
|
return null;
|
|
}
|
|
|
|
for (const page of parsedDocument.pages) {
|
|
const found = page.blocks.find((block) => block.id === locator.blockId);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isRectOverlap(
|
|
a: { left: number; top: number; right: number; bottom: number },
|
|
b: { left: number; top: number; right: number; bottom: number },
|
|
): boolean {
|
|
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
|
}
|
|
|
|
function collectSpanNodesForParsedBlock(
|
|
container: HTMLElement,
|
|
parsedDocument: ParsedPdfDocument,
|
|
locator: TTSSegmentLocator,
|
|
): HTMLElement[] | null {
|
|
const block = resolveParsedBlock(parsedDocument, locator);
|
|
if (!block) return null;
|
|
|
|
const collected: HTMLElement[] = [];
|
|
const seen = new Set<HTMLElement>();
|
|
|
|
for (const fragment of block.fragments) {
|
|
const parsedPage = parsedDocument.pages.find((page) => page.pageNumber === fragment.page);
|
|
if (!parsedPage || parsedPage.width <= 0 || parsedPage.height <= 0) continue;
|
|
|
|
const pageElement = findRenderedPageElement(container, fragment.page);
|
|
if (!pageElement) continue;
|
|
|
|
const textLayer = pageElement.querySelector('.react-pdf__Page__textContent') as HTMLElement | null;
|
|
if (!textLayer) continue;
|
|
|
|
const pageRect = pageElement.getBoundingClientRect();
|
|
const [x0, y0, x1, y1] = fragment.bbox;
|
|
const blockRect = {
|
|
left: (x0 / parsedPage.width) * pageRect.width,
|
|
top: (y0 / parsedPage.height) * pageRect.height,
|
|
right: (x1 / parsedPage.width) * pageRect.width,
|
|
bottom: (y1 / parsedPage.height) * pageRect.height,
|
|
};
|
|
|
|
const spans = Array.from(textLayer.querySelectorAll('span')) as HTMLElement[];
|
|
for (const span of spans) {
|
|
const node = span.firstChild;
|
|
if (!node || node.nodeType !== Node.TEXT_NODE) continue;
|
|
|
|
const rect = span.getBoundingClientRect();
|
|
const spanRect = {
|
|
left: rect.left - pageRect.left,
|
|
top: rect.top - pageRect.top,
|
|
right: rect.right - pageRect.left,
|
|
bottom: rect.bottom - pageRect.top,
|
|
};
|
|
|
|
if (!isRectOverlap(spanRect, blockRect)) continue;
|
|
if (seen.has(span)) continue;
|
|
seen.add(span);
|
|
collected.push(span);
|
|
}
|
|
}
|
|
|
|
return collected.length > 0 ? collected : null;
|
|
}
|
|
|
|
export function clearHighlights() {
|
|
const overlays = document.querySelectorAll('.pdf-text-highlight-overlay');
|
|
overlays.forEach((node) => {
|
|
const element = node as HTMLElement;
|
|
if (element.parentElement) {
|
|
element.parentElement.removeChild(element);
|
|
}
|
|
});
|
|
const wordOverlays = document.querySelectorAll('.pdf-word-highlight-overlay');
|
|
wordOverlays.forEach((node) => {
|
|
const element = node as HTMLElement;
|
|
if (element.parentElement) {
|
|
element.parentElement.removeChild(element);
|
|
}
|
|
});
|
|
}
|
|
|
|
export function clearWordHighlights() {
|
|
const wordOverlays = document.querySelectorAll('.pdf-word-highlight-overlay');
|
|
wordOverlays.forEach((node) => {
|
|
const element = node as HTMLElement;
|
|
if (element.parentElement) {
|
|
element.parentElement.removeChild(element);
|
|
}
|
|
});
|
|
}
|
|
|
|
export function highlightPattern(
|
|
text: string,
|
|
pattern: string,
|
|
containerRef: React.RefObject<HTMLDivElement>,
|
|
options?: HighlightPatternOptions,
|
|
) {
|
|
const seq = ++highlightPatternSeq;
|
|
clearHighlights();
|
|
|
|
if (!pattern?.trim()) return;
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
|
|
if (!cleanPattern) return;
|
|
lastSentencePattern = cleanPattern;
|
|
lastSentenceWordToTokenRangeMap = null;
|
|
lastSentenceTokenWindow = null;
|
|
const parsedDocument = options?.parsedDocument ?? null;
|
|
const locator = options?.locator ?? null;
|
|
|
|
// Canonical path: parsed block locator is required for PDF sentence
|
|
// highlighting. Avoid broad full-page text matching fallbacks.
|
|
if (!parsedDocument || !locator || locator.readerType !== 'pdf' || !locator.blockId) {
|
|
return;
|
|
}
|
|
|
|
const spanNodes = collectSpanNodesForParsedBlock(container, parsedDocument, locator) ?? [];
|
|
|
|
if (!spanNodes.length) {
|
|
if (options?.useBlockGeometryOnly) {
|
|
highlightParsedBlockGeometry(containerRef, parsedDocument, locator);
|
|
}
|
|
return;
|
|
}
|
|
lastSpanNodes = spanNodes;
|
|
|
|
const tokens: PDFToken[] = [];
|
|
|
|
spanNodes.forEach((span, spanIndex) => {
|
|
const node = span.firstChild;
|
|
if (!node || node.nodeType !== Node.TEXT_NODE) return;
|
|
|
|
const textNode = node as Text;
|
|
const textContent = textNode.textContent || '';
|
|
for (const token of segmentWords(textContent, options?.language)) {
|
|
tokens.push({
|
|
spanIndex,
|
|
textNode,
|
|
text: token.text,
|
|
startOffset: token.start,
|
|
endOffset: token.end,
|
|
});
|
|
}
|
|
});
|
|
|
|
if (!tokens.length) return;
|
|
lastTokens = tokens;
|
|
|
|
if (options?.useBlockGeometryOnly) {
|
|
lastSentenceTokenWindow = {
|
|
start: 0,
|
|
end: tokens.length - 1,
|
|
};
|
|
highlightParsedBlockGeometry(containerRef, parsedDocument, locator);
|
|
return;
|
|
}
|
|
|
|
const patternLen = cleanPattern.length;
|
|
|
|
// Core application of highlight logic once we know the best token window (if any)
|
|
const applyHighlightFromTokens = (
|
|
tokenMatch:
|
|
| {
|
|
bestStart: number;
|
|
bestEnd: number;
|
|
rating: number;
|
|
lengthDiff: number;
|
|
}
|
|
| null
|
|
) => {
|
|
const highlightRanges: Array<{
|
|
textNode: Text;
|
|
startOffset: number;
|
|
endOffset: number;
|
|
span: HTMLElement;
|
|
}> = [];
|
|
|
|
let bestStart = -1;
|
|
let bestEnd = -1;
|
|
let bestRating = 0;
|
|
let bestLengthDiff = Infinity;
|
|
|
|
if (tokenMatch) {
|
|
bestStart = tokenMatch.bestStart;
|
|
bestEnd = tokenMatch.bestEnd;
|
|
bestRating = tokenMatch.rating;
|
|
bestLengthDiff = tokenMatch.lengthDiff;
|
|
}
|
|
|
|
const hasTokenMatch = bestStart !== -1;
|
|
const similarityThreshold =
|
|
bestLengthDiff < patternLen * 0.3 ? 0.3 : 0.5;
|
|
|
|
if (hasTokenMatch && bestRating >= similarityThreshold) {
|
|
lastSentenceTokenWindow = {
|
|
start: bestStart,
|
|
end: bestEnd,
|
|
};
|
|
|
|
const rangesBySpan = new Map<
|
|
number,
|
|
{ startOffset: number; endOffset: number }
|
|
>();
|
|
|
|
for (let i = bestStart; i <= bestEnd; i++) {
|
|
const token = tokens[i];
|
|
const existing = rangesBySpan.get(token.spanIndex);
|
|
if (!existing) {
|
|
rangesBySpan.set(token.spanIndex, {
|
|
startOffset: token.startOffset,
|
|
endOffset: token.endOffset,
|
|
});
|
|
} else {
|
|
existing.startOffset = Math.min(
|
|
existing.startOffset,
|
|
token.startOffset
|
|
);
|
|
existing.endOffset = Math.max(
|
|
existing.endOffset,
|
|
token.endOffset
|
|
);
|
|
}
|
|
}
|
|
|
|
rangesBySpan.forEach(({ startOffset, endOffset }, spanIndex) => {
|
|
const span = spanNodes[spanIndex];
|
|
const node = span.firstChild;
|
|
if (!node || node.nodeType !== Node.TEXT_NODE) return;
|
|
|
|
highlightRanges.push({
|
|
textNode: node as Text,
|
|
startOffset,
|
|
endOffset,
|
|
span,
|
|
});
|
|
});
|
|
}
|
|
|
|
if (!highlightRanges.length) return;
|
|
|
|
// Create overlay rectangles for each range, relative to its page text layer
|
|
const scrollIntoViewRects: DOMRect[] = [];
|
|
|
|
highlightRanges.forEach(({ textNode, startOffset, endOffset, span }) => {
|
|
try {
|
|
const range = document.createRange();
|
|
range.setStart(textNode, startOffset);
|
|
range.setEnd(textNode, endOffset);
|
|
|
|
const highlightTarget = getOrCreateHighlightLayer(span);
|
|
if (!highlightTarget) return;
|
|
|
|
const { layer: highlightLayer, pageRect } = highlightTarget;
|
|
const rects = Array.from(range.getClientRects());
|
|
|
|
rects.forEach((rect) => {
|
|
const highlight = document.createElement('div');
|
|
highlight.className = 'pdf-text-highlight-overlay';
|
|
highlight.style.position = 'absolute';
|
|
highlight.style.backgroundColor = 'grey';
|
|
highlight.style.opacity = '0.4';
|
|
highlight.style.pointerEvents = 'none';
|
|
highlight.style.zIndex = '1';
|
|
highlight.style.left = `${rect.left - pageRect.left}px`;
|
|
highlight.style.top = `${rect.top - pageRect.top}px`;
|
|
highlight.style.width = `${rect.width}px`;
|
|
highlight.style.height = `${rect.height}px`;
|
|
highlightLayer.appendChild(highlight);
|
|
|
|
scrollIntoViewRects.push(rect);
|
|
});
|
|
} catch {
|
|
// If range creation fails for any reason, skip this segment
|
|
}
|
|
});
|
|
|
|
if (!scrollIntoViewRects.length) return;
|
|
|
|
// Scroll the first highlighted rect into view if needed
|
|
const containerRect = container.getBoundingClientRect();
|
|
const visibleTop = container.scrollTop;
|
|
const visibleBottom = visibleTop + containerRect.height;
|
|
|
|
const firstRect = scrollIntoViewRects[0];
|
|
const elementTop =
|
|
firstRect.top - containerRect.top + container.scrollTop;
|
|
|
|
if (elementTop < visibleTop || elementTop > visibleBottom) {
|
|
container.scrollTo({
|
|
top: elementTop - containerRect.height / 3,
|
|
behavior: 'smooth',
|
|
});
|
|
}
|
|
};
|
|
|
|
const tokenTexts = tokens.map((t) => t.text);
|
|
const patternTokens = segmentWords(cleanPattern, options?.language).map((token) => token.text);
|
|
|
|
// Fire-and-forget async worker call; UI thread returns immediately
|
|
runHighlightTokenMatch(patternTokens, tokenTexts)
|
|
.then((result) => {
|
|
if (seq !== highlightPatternSeq) return;
|
|
if (!result || result.bestStart === -1) {
|
|
// No worker result or no good match; nothing to highlight
|
|
applyHighlightFromTokens(null);
|
|
} else {
|
|
applyHighlightFromTokens({
|
|
bestStart: result.bestStart,
|
|
bestEnd: result.bestEnd,
|
|
rating: result.rating,
|
|
lengthDiff: result.lengthDiff,
|
|
});
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
if (seq !== highlightPatternSeq) return;
|
|
console.error(
|
|
'Error in PDF highlight worker; no highlights applied:',
|
|
error
|
|
);
|
|
applyHighlightFromTokens(null);
|
|
});
|
|
}
|
|
|
|
export function highlightWordIndex(
|
|
alignment: TTSSentenceAlignment | undefined,
|
|
wordIndex: number | null | undefined,
|
|
sentence: string | null | undefined,
|
|
containerRef: React.RefObject<HTMLDivElement>
|
|
) {
|
|
clearWordHighlights();
|
|
|
|
if (!alignment) return;
|
|
if (wordIndex === null || wordIndex === undefined || wordIndex < 0) {
|
|
return;
|
|
}
|
|
|
|
const words = alignment.words || [];
|
|
if (!words.length || wordIndex >= words.length) return;
|
|
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
if (!lastSentenceTokenWindow) return;
|
|
if (!lastTokens.length || !lastSpanNodes.length) return;
|
|
|
|
const cleanSentence =
|
|
sentence && sentence.trim()
|
|
? sentence.trim().replace(/\s+/g, ' ')
|
|
: null;
|
|
if (!cleanSentence || !lastSentencePattern) return;
|
|
if (cleanSentence !== lastSentencePattern) return;
|
|
|
|
const start = lastSentenceTokenWindow.start;
|
|
const end = lastSentenceTokenWindow.end;
|
|
if (end < start) return;
|
|
|
|
// Lazily build or refresh the shared mapping from alignment words to PDF
|
|
// token ranges for this sentence window.
|
|
if (
|
|
!lastSentenceWordToTokenRangeMap ||
|
|
lastSentenceWordToTokenRangeMap.length !== words.length
|
|
) {
|
|
const relativeRanges = buildAlignmentTokenRanges(
|
|
words,
|
|
lastTokens.slice(start, end + 1).map((token) => token.text),
|
|
{ fillGaps: true },
|
|
);
|
|
lastSentenceWordToTokenRangeMap = relativeRanges.map((range) => (
|
|
range ? { start: range.start + start, end: range.end + start } : null
|
|
));
|
|
}
|
|
|
|
const tokenRange = lastSentenceWordToTokenRangeMap[wordIndex];
|
|
if (!tokenRange) return;
|
|
|
|
for (let tokenIndex = tokenRange.start; tokenIndex <= tokenRange.end; tokenIndex += 1) {
|
|
const token = lastTokens[tokenIndex];
|
|
const span = lastSpanNodes[token.spanIndex];
|
|
if (!span) continue;
|
|
|
|
const node = token.textNode;
|
|
if (!node || node.nodeType !== Node.TEXT_NODE) continue;
|
|
|
|
try {
|
|
const range = document.createRange();
|
|
range.setStart(node, token.startOffset);
|
|
range.setEnd(node, token.endOffset);
|
|
|
|
const highlightTarget = getOrCreateHighlightLayer(span);
|
|
if (!highlightTarget) continue;
|
|
|
|
const { layer: highlightLayer, pageRect } = highlightTarget;
|
|
const rects = Array.from(range.getClientRects());
|
|
|
|
rects.forEach((rect) => {
|
|
const highlight = document.createElement('div');
|
|
highlight.className = 'pdf-word-highlight-overlay';
|
|
highlight.style.position = 'absolute';
|
|
highlight.style.backgroundColor = 'var(--accent)';
|
|
highlight.style.opacity = '0.4';
|
|
highlight.style.pointerEvents = 'none';
|
|
highlight.style.left = `${rect.left - pageRect.left}px`;
|
|
highlight.style.top = `${rect.top - pageRect.top}px`;
|
|
highlight.style.width = `${rect.width}px`;
|
|
highlight.style.height = `${rect.height}px`;
|
|
highlight.style.zIndex = '2';
|
|
highlightLayer.appendChild(highlight);
|
|
});
|
|
} catch {
|
|
// Ignore range errors
|
|
}
|
|
}
|
|
}
|
|
|
|
// Debounce for PDF viewer
|
|
export function debounce<T extends (...args: unknown[]) => unknown>(
|
|
func: T,
|
|
wait: number
|
|
): (...args: Parameters<T>) => void {
|
|
let timeout: NodeJS.Timeout;
|
|
return (...args: Parameters<T>) => {
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => func(...args), wait);
|
|
};
|
|
}
|