@epam/pdf-highlighter-kit 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +219 -0
- package/dist/PDFHighlightViewer.d.ts +219 -0
- package/dist/PDFHighlightViewer.d.ts.map +1 -0
- package/dist/PDFHighlightViewer.js +1551 -0
- package/dist/PDFHighlightViewer.js.map +1 -0
- package/dist/api.d.ts +59 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +2 -0
- package/dist/api.js.map +1 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +32 -0
- package/dist/config.js.map +1 -0
- package/dist/core/interaction-handler.d.ts +63 -0
- package/dist/core/interaction-handler.d.ts.map +1 -0
- package/dist/core/interaction-handler.js +430 -0
- package/dist/core/interaction-handler.js.map +1 -0
- package/dist/core/pdf-engine.d.ts +37 -0
- package/dist/core/pdf-engine.d.ts.map +1 -0
- package/dist/core/pdf-engine.js +281 -0
- package/dist/core/pdf-engine.js.map +1 -0
- package/dist/core/performance-optimizer.d.ts +91 -0
- package/dist/core/performance-optimizer.d.ts.map +1 -0
- package/dist/core/performance-optimizer.js +473 -0
- package/dist/core/performance-optimizer.js.map +1 -0
- package/dist/core/style-manager.d.ts +88 -0
- package/dist/core/style-manager.d.ts.map +1 -0
- package/dist/core/style-manager.js +413 -0
- package/dist/core/style-manager.js.map +1 -0
- package/dist/core/text-segmentation.d.ts +41 -0
- package/dist/core/text-segmentation.d.ts.map +1 -0
- package/dist/core/text-segmentation.js +338 -0
- package/dist/core/text-segmentation.js.map +1 -0
- package/dist/core/unified-layer-builder.d.ts +27 -0
- package/dist/core/unified-layer-builder.d.ts.map +1 -0
- package/dist/core/unified-layer-builder.js +331 -0
- package/dist/core/unified-layer-builder.js.map +1 -0
- package/dist/core/viewport-manager.d.ts +103 -0
- package/dist/core/viewport-manager.d.ts.map +1 -0
- package/dist/core/viewport-manager.js +222 -0
- package/dist/core/viewport-manager.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -0
- package/dist/pdf-highlight-viewer.css +488 -0
- package/dist/types.d.ts +279 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/highlight-adapter.d.ts +31 -0
- package/dist/utils/highlight-adapter.d.ts.map +1 -0
- package/dist/utils/highlight-adapter.js +202 -0
- package/dist/utils/highlight-adapter.js.map +1 -0
- package/dist/utils/pdf-utils.d.ts +14 -0
- package/dist/utils/pdf-utils.d.ts.map +1 -0
- package/dist/utils/pdf-utils.js +120 -0
- package/dist/utils/pdf-utils.js.map +1 -0
- package/dist/utils/worker-loader-simple.d.ts +8 -0
- package/dist/utils/worker-loader-simple.d.ts.map +1 -0
- package/dist/utils/worker-loader-simple.js +65 -0
- package/dist/utils/worker-loader-simple.js.map +1 -0
- package/dist/vite.config.d.ts +3 -0
- package/dist/vite.config.d.ts.map +1 -0
- package/dist/vite.config.js +20 -0
- package/dist/vite.config.js.map +1 -0
- package/dist/vitest.config.d.ts +3 -0
- package/dist/vitest.config.d.ts.map +1 -0
- package/dist/vitest.config.js +24 -0
- package/dist/vitest.config.js.map +1 -0
- package/dist/vitest.setup.d.ts +2 -0
- package/dist/vitest.setup.d.ts.map +1 -0
- package/dist/vitest.setup.js +8 -0
- package/dist/vitest.setup.js.map +1 -0
- package/package.json +74 -0
- package/styles/pdf-highlight-viewer.css +488 -0
|
@@ -0,0 +1,1551 @@
|
|
|
1
|
+
import { PDFEngine } from './core/pdf-engine';
|
|
2
|
+
import { ViewportManager } from './core/viewport-manager';
|
|
3
|
+
import { UnifiedLayerBuilder } from './core/unified-layer-builder';
|
|
4
|
+
import { UnifiedInteractionHandler } from './core/interaction-handler';
|
|
5
|
+
import { PerformanceOptimizer } from './core/performance-optimizer';
|
|
6
|
+
import { CategoryStyleManager } from './core/style-manager';
|
|
7
|
+
import { adaptHighlightData } from './utils/highlight-adapter';
|
|
8
|
+
export class PDFHighlightViewer {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.container = null;
|
|
11
|
+
this.pdfContainer = null;
|
|
12
|
+
this.pageContainers = new Map();
|
|
13
|
+
this.highlightData = {};
|
|
14
|
+
this.currentPage = 1;
|
|
15
|
+
this.currentScale = 1.5;
|
|
16
|
+
this.totalPages = 0;
|
|
17
|
+
this.selectedTermId = null;
|
|
18
|
+
this.isInitialized = false;
|
|
19
|
+
this.pageDimensions = new Map();
|
|
20
|
+
this.defaultPageHeight = 800;
|
|
21
|
+
this.eventListeners = [];
|
|
22
|
+
this.scrollListener = null;
|
|
23
|
+
this.analytics = {
|
|
24
|
+
totalHighlights: 0,
|
|
25
|
+
categoryBreakdown: {},
|
|
26
|
+
mostViewedPages: [],
|
|
27
|
+
interactionHeatmap: {},
|
|
28
|
+
averageTimePerPage: 0,
|
|
29
|
+
};
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Text Selection Implementation
|
|
32
|
+
// =============================================================================
|
|
33
|
+
this.textSelection = {
|
|
34
|
+
enable: () => {
|
|
35
|
+
this.interactionHandler.setInteractionMode('select');
|
|
36
|
+
},
|
|
37
|
+
disable: () => {
|
|
38
|
+
this.interactionHandler.setInteractionMode('highlight');
|
|
39
|
+
},
|
|
40
|
+
getSelection: () => {
|
|
41
|
+
const selection = window.getSelection();
|
|
42
|
+
return selection ? selection.toString() : '';
|
|
43
|
+
},
|
|
44
|
+
getSelectionWithContext: () => {
|
|
45
|
+
return this.interactionHandler.getSelectionWithContext();
|
|
46
|
+
},
|
|
47
|
+
clearSelection: () => {
|
|
48
|
+
this.interactionHandler.clearSelection();
|
|
49
|
+
},
|
|
50
|
+
selectText: (range) => {
|
|
51
|
+
// TODO: Implement programmatic text selection
|
|
52
|
+
console.log('selectText not yet implemented:', range);
|
|
53
|
+
},
|
|
54
|
+
copySelection: (format = 'plain') => {
|
|
55
|
+
const selection = this.textSelection.getSelection();
|
|
56
|
+
if (selection) {
|
|
57
|
+
navigator.clipboard.writeText(selection).catch(console.error);
|
|
58
|
+
this.emit('selectionCopied', { text: selection, format });
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
createHighlightFromSelection: (category) => {
|
|
62
|
+
const selectionData = this.textSelection.getSelectionWithContext();
|
|
63
|
+
if (!selectionData)
|
|
64
|
+
return null;
|
|
65
|
+
// Create new term occurrence from selection
|
|
66
|
+
const termId = `selection-${Date.now()}`;
|
|
67
|
+
const occurrence = {
|
|
68
|
+
termId,
|
|
69
|
+
coordinates: [], // TODO: Calculate coordinates from selection
|
|
70
|
+
};
|
|
71
|
+
// Add to highlights
|
|
72
|
+
selectionData.pages.forEach((pageNumber) => {
|
|
73
|
+
this.addHighlight(pageNumber, occurrence);
|
|
74
|
+
});
|
|
75
|
+
this.emit('selectionHighlighted', { text: selectionData.text, category, coordinates: [] });
|
|
76
|
+
return occurrence;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
// =============================================================================
|
|
80
|
+
// Accessibility
|
|
81
|
+
// =============================================================================
|
|
82
|
+
this.accessibility = {
|
|
83
|
+
enableKeyboardNavigation: () => {
|
|
84
|
+
// Already enabled in setupAccessibility
|
|
85
|
+
},
|
|
86
|
+
enableScreenReader: () => {
|
|
87
|
+
// Add screen reader support
|
|
88
|
+
if (this.container) {
|
|
89
|
+
this.container.setAttribute('aria-live', 'polite');
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
setAriaLabels: (labels) => {
|
|
93
|
+
// Apply ARIA labels
|
|
94
|
+
Object.entries(labels).forEach(([selector, label]) => {
|
|
95
|
+
const elements = this.container?.querySelectorAll(selector);
|
|
96
|
+
elements?.forEach((el) => el.setAttribute('aria-label', label));
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
announceHighlight: (termId) => {
|
|
100
|
+
// Announce highlight to screen readers
|
|
101
|
+
const announcement = document.createElement('div');
|
|
102
|
+
announcement.setAttribute('aria-live', 'assertive');
|
|
103
|
+
announcement.setAttribute('aria-atomic', 'true');
|
|
104
|
+
announcement.style.position = 'absolute';
|
|
105
|
+
announcement.style.left = '-10000px';
|
|
106
|
+
announcement.textContent = `Highlighted term: ${termId}`;
|
|
107
|
+
document.body.appendChild(announcement);
|
|
108
|
+
setTimeout(() => document.body.removeChild(announcement), 1000);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
this.options = {
|
|
112
|
+
enableTextSelection: false,
|
|
113
|
+
enableVirtualScrolling: true,
|
|
114
|
+
bufferPages: 2,
|
|
115
|
+
maxCachedPages: 10,
|
|
116
|
+
interactionMode: 'hybrid',
|
|
117
|
+
performanceMode: false,
|
|
118
|
+
accessibility: true,
|
|
119
|
+
};
|
|
120
|
+
this.pdfEngine = new PDFEngine(this.options);
|
|
121
|
+
this.viewportManager = new ViewportManager(this.options.bufferPages, this.options.maxCachedPages);
|
|
122
|
+
this.layerBuilder = new UnifiedLayerBuilder();
|
|
123
|
+
this.performanceOptimizer = new PerformanceOptimizer({
|
|
124
|
+
maxCacheSize: this.options.maxCachedPages ? this.options.maxCachedPages * 10 : 100,
|
|
125
|
+
frameBudget: this.options.performanceMode ? 8 : 16,
|
|
126
|
+
});
|
|
127
|
+
this.styleManager = new CategoryStyleManager();
|
|
128
|
+
// Setup interaction callbacks
|
|
129
|
+
const interactionCallbacks = {
|
|
130
|
+
onHighlightHover: (event) => this.emit('highlightHover', event),
|
|
131
|
+
onHighlightBlur: (termId) => this.emit('highlightBlur', termId),
|
|
132
|
+
onHighlightClick: (event) => this.emit('highlightClick', event),
|
|
133
|
+
onTextSelected: (event) => this.emit('textSelected', event),
|
|
134
|
+
onSelectionChanged: (selection) => this.emit('selectionChanged', selection),
|
|
135
|
+
onInteractionModeChanged: (mode) => this.emit('interactionModeChanged', mode),
|
|
136
|
+
};
|
|
137
|
+
this.interactionHandler = new UnifiedInteractionHandler(interactionCallbacks);
|
|
138
|
+
}
|
|
139
|
+
// =============================================================================
|
|
140
|
+
// Initialization
|
|
141
|
+
// =============================================================================
|
|
142
|
+
async init(container, options) {
|
|
143
|
+
if (this.isInitialized) {
|
|
144
|
+
throw new Error('Viewer is already initialized');
|
|
145
|
+
}
|
|
146
|
+
// Merge options
|
|
147
|
+
this.options = { ...this.options, ...options };
|
|
148
|
+
// Store container reference
|
|
149
|
+
this.container = container;
|
|
150
|
+
// Setup container
|
|
151
|
+
this.setupContainer();
|
|
152
|
+
// Load CSS
|
|
153
|
+
this.loadCSS();
|
|
154
|
+
// Initialize interaction handler
|
|
155
|
+
this.interactionHandler.init(this.container);
|
|
156
|
+
// Setup scroll handling
|
|
157
|
+
this.setupScrollHandling();
|
|
158
|
+
// Initialize accessibility if enabled
|
|
159
|
+
if (this.options.accessibility) {
|
|
160
|
+
this.setupAccessibility();
|
|
161
|
+
}
|
|
162
|
+
this.isInitialized = true;
|
|
163
|
+
this.emit('initialized');
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Setup container with proper structure and styling
|
|
167
|
+
*/
|
|
168
|
+
setupContainer() {
|
|
169
|
+
if (!this.container)
|
|
170
|
+
return;
|
|
171
|
+
// Add viewer class
|
|
172
|
+
this.container.className = (this.container.className + ' pdf-highlight-viewer').trim();
|
|
173
|
+
// Create PDF container
|
|
174
|
+
this.pdfContainer = document.createElement('div');
|
|
175
|
+
this.pdfContainer.className = 'pdf-container';
|
|
176
|
+
this.container.appendChild(this.pdfContainer);
|
|
177
|
+
// Setup container styles
|
|
178
|
+
this.container.style.position = 'relative';
|
|
179
|
+
this.container.style.overflow = 'auto';
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Load CSS dynamically
|
|
183
|
+
*/
|
|
184
|
+
loadCSS() {
|
|
185
|
+
// Check if CSS is already loaded
|
|
186
|
+
if (document.getElementById('pdf-highlight-viewer-styles')) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const style = document.createElement('style');
|
|
190
|
+
style.id = 'pdf-highlight-viewer-styles';
|
|
191
|
+
style.textContent = `
|
|
192
|
+
.pdf-highlight-viewer {
|
|
193
|
+
position: relative;
|
|
194
|
+
width: 100%;
|
|
195
|
+
height: 100%;
|
|
196
|
+
overflow: auto;
|
|
197
|
+
background: #f5f5f5;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.pdf-container {
|
|
201
|
+
position: relative;
|
|
202
|
+
margin: 0 auto;
|
|
203
|
+
padding: 20px;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.pdf-page-container {
|
|
207
|
+
position: relative;
|
|
208
|
+
margin: 0 auto 20px;
|
|
209
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
210
|
+
background: white;
|
|
211
|
+
border-radius: 4px;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.pdf-canvas {
|
|
215
|
+
position: relative;
|
|
216
|
+
z-index: 1;
|
|
217
|
+
display: block;
|
|
218
|
+
width: 100%;
|
|
219
|
+
height: auto;
|
|
220
|
+
user-select: none;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.unified-layer {
|
|
224
|
+
position: absolute;
|
|
225
|
+
top: 0;
|
|
226
|
+
left: 0;
|
|
227
|
+
right: 0;
|
|
228
|
+
bottom: 0;
|
|
229
|
+
z-index: 2;
|
|
230
|
+
pointer-events: none;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.highlight-wrapper {
|
|
234
|
+
position: absolute;
|
|
235
|
+
pointer-events: all;
|
|
236
|
+
cursor: pointer;
|
|
237
|
+
transition: opacity 0.2s ease;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.highlight-wrapper:hover {
|
|
241
|
+
opacity: 0.8;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.highlight-background {
|
|
245
|
+
position: absolute;
|
|
246
|
+
top: 0;
|
|
247
|
+
left: 0;
|
|
248
|
+
right: 0;
|
|
249
|
+
bottom: 0;
|
|
250
|
+
z-index: -1;
|
|
251
|
+
border-radius: 2px;
|
|
252
|
+
opacity: 0.3;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/* Category-specific highlight colors */
|
|
256
|
+
.protein-highlight .highlight-background {
|
|
257
|
+
background-color: #ff6b6b;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.species-highlight .highlight-background {
|
|
261
|
+
background-color: #4ecdc4;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.chemical-highlight .highlight-background {
|
|
265
|
+
background-color: #45b7d1;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.disease-highlight .highlight-background {
|
|
269
|
+
background-color: #f7b731;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.gene-highlight .highlight-background {
|
|
273
|
+
background-color: #5f27cd;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.cell_line-highlight .highlight-background {
|
|
277
|
+
background-color: #00d2d3;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* Text segment styling */
|
|
281
|
+
.text-segment {
|
|
282
|
+
position: relative;
|
|
283
|
+
z-index: 1;
|
|
284
|
+
color: #333;
|
|
285
|
+
font-family: inherit;
|
|
286
|
+
white-space: pre;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.text-segment.selectable {
|
|
290
|
+
user-select: text;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* Loading states */
|
|
294
|
+
.pdf-page-container.loading {
|
|
295
|
+
min-height: 800px;
|
|
296
|
+
display: flex;
|
|
297
|
+
align-items: center;
|
|
298
|
+
justify-content: center;
|
|
299
|
+
color: #666;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.pdf-page-container.loading::after {
|
|
303
|
+
content: "Loading page...";
|
|
304
|
+
position: absolute;
|
|
305
|
+
top: 50%;
|
|
306
|
+
left: 50%;
|
|
307
|
+
transform: translate(-50%, -50%);
|
|
308
|
+
font-size: 12px;
|
|
309
|
+
color: #999;
|
|
310
|
+
background: rgba(255, 255, 255, 0.9);
|
|
311
|
+
padding: 4px 8px;
|
|
312
|
+
border-radius: 4px;
|
|
313
|
+
pointer-events: none;
|
|
314
|
+
z-index: 10;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
`;
|
|
318
|
+
document.head.appendChild(style);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Setup scroll handling for virtual viewport management
|
|
322
|
+
*/
|
|
323
|
+
setupScrollHandling() {
|
|
324
|
+
if (!this.container)
|
|
325
|
+
return;
|
|
326
|
+
let scrollTimeout;
|
|
327
|
+
let isScrolling = false;
|
|
328
|
+
this.scrollListener = () => {
|
|
329
|
+
if (!isScrolling) {
|
|
330
|
+
isScrolling = true;
|
|
331
|
+
this.container?.classList.add('scrolling');
|
|
332
|
+
}
|
|
333
|
+
clearTimeout(scrollTimeout);
|
|
334
|
+
scrollTimeout = window.setTimeout(() => {
|
|
335
|
+
isScrolling = false;
|
|
336
|
+
this.container?.classList.remove('scrolling');
|
|
337
|
+
this.handleScroll();
|
|
338
|
+
}, 50); // Reduced debounce time for better responsiveness
|
|
339
|
+
};
|
|
340
|
+
this.container.addEventListener('scroll', this.scrollListener);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Handle scroll events for virtual viewport management
|
|
344
|
+
*/
|
|
345
|
+
handleScroll() {
|
|
346
|
+
if (!this.container)
|
|
347
|
+
return;
|
|
348
|
+
const scrollTop = this.container.scrollTop;
|
|
349
|
+
const containerHeight = this.container.clientHeight;
|
|
350
|
+
// Get what pages should be visible
|
|
351
|
+
const visiblePages = this.viewportManager.getVisiblePages(scrollTop, containerHeight);
|
|
352
|
+
const bufferPages = this.viewportManager.getBufferPages(visiblePages, 1);
|
|
353
|
+
// 1. RENDER VISIBLE PAGES IMMEDIATELY - No delays, no complex logic
|
|
354
|
+
visiblePages.forEach((pageNumber) => {
|
|
355
|
+
const pageContainer = this.pageContainers.get(pageNumber);
|
|
356
|
+
if (pageContainer && !pageContainer.classList.contains('rendered')) {
|
|
357
|
+
// Render immediately for visible pages
|
|
358
|
+
this.renderPage(pageNumber).catch((error) => {
|
|
359
|
+
console.error(`Failed to render visible page ${pageNumber}:`, error);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
// 2. RENDER BUFFER PAGES WITH SMALL DELAY
|
|
364
|
+
setTimeout(() => {
|
|
365
|
+
bufferPages.forEach((pageNumber) => {
|
|
366
|
+
const pageContainer = this.pageContainers.get(pageNumber);
|
|
367
|
+
if (pageContainer && !pageContainer.classList.contains('rendered')) {
|
|
368
|
+
this.renderPage(pageNumber).catch((error) => {
|
|
369
|
+
console.error(`Failed to render buffer page ${pageNumber}:`, error);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
}, 100);
|
|
374
|
+
// 3. UNLOAD DISTANT PAGES (simple logic)
|
|
375
|
+
const allRelevantPages = new Set([...visiblePages, ...bufferPages]);
|
|
376
|
+
this.pageContainers.forEach((pageContainer, pageNumber) => {
|
|
377
|
+
if (!allRelevantPages.has(pageNumber) && pageContainer.classList.contains('rendered')) {
|
|
378
|
+
const distance = Math.min(...visiblePages.map((vp) => Math.abs(pageNumber - vp)));
|
|
379
|
+
if (distance > 5) {
|
|
380
|
+
// Only unload if really far
|
|
381
|
+
pageContainer.innerHTML = '';
|
|
382
|
+
pageContainer.classList.remove('rendered');
|
|
383
|
+
this.setPagePlaceholderSize(pageContainer, pageNumber);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
// Update current page
|
|
388
|
+
if (visiblePages.length > 0) {
|
|
389
|
+
const newCurrentPage = visiblePages[0];
|
|
390
|
+
if (newCurrentPage !== this.currentPage) {
|
|
391
|
+
const previousPage = this.currentPage;
|
|
392
|
+
this.currentPage = newCurrentPage;
|
|
393
|
+
this.emit('pageChanged', {
|
|
394
|
+
currentPage: newCurrentPage,
|
|
395
|
+
previousPage: previousPage,
|
|
396
|
+
totalPages: this.totalPages,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Set placeholder size for a page container using real PDF dimensions
|
|
403
|
+
*/
|
|
404
|
+
setPagePlaceholderSize(pageContainer, pageNumber) {
|
|
405
|
+
const dimensions = this.pageDimensions.get(pageNumber);
|
|
406
|
+
if (dimensions) {
|
|
407
|
+
pageContainer.style.height = `${dimensions.height}px`;
|
|
408
|
+
pageContainer.style.width = `${dimensions.width}px`;
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
// Fallback to default height
|
|
412
|
+
pageContainer.style.height = `${this.defaultPageHeight}px`;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Get and cache real page dimensions
|
|
417
|
+
*/
|
|
418
|
+
async getPageDimensions(pageNumber) {
|
|
419
|
+
// Return cached dimensions if available
|
|
420
|
+
const cached = this.pageDimensions.get(pageNumber);
|
|
421
|
+
if (cached)
|
|
422
|
+
return cached;
|
|
423
|
+
try {
|
|
424
|
+
// Get PDF page and its viewport at current scale
|
|
425
|
+
const page = await this.pdfEngine.getPage(pageNumber);
|
|
426
|
+
const viewport = page.getViewport({ scale: this.currentScale });
|
|
427
|
+
const dimensions = {
|
|
428
|
+
width: viewport.width,
|
|
429
|
+
height: viewport.height,
|
|
430
|
+
};
|
|
431
|
+
// Cache the dimensions
|
|
432
|
+
this.pageDimensions.set(pageNumber, dimensions);
|
|
433
|
+
return dimensions;
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
console.error(`Failed to get dimensions for page ${pageNumber}:`, error);
|
|
437
|
+
return { width: 600, height: this.defaultPageHeight };
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Setup accessibility features
|
|
442
|
+
*/
|
|
443
|
+
setupAccessibility() {
|
|
444
|
+
if (!this.container)
|
|
445
|
+
return;
|
|
446
|
+
// Add ARIA attributes
|
|
447
|
+
this.container.setAttribute('role', 'application');
|
|
448
|
+
this.container.setAttribute('aria-label', 'PDF Highlight Viewer');
|
|
449
|
+
this.container.setAttribute('tabindex', '0');
|
|
450
|
+
// Add keyboard navigation
|
|
451
|
+
this.container.addEventListener('keydown', this.handleKeyboardNavigation.bind(this));
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Handle keyboard navigation
|
|
455
|
+
*/
|
|
456
|
+
handleKeyboardNavigation(event) {
|
|
457
|
+
switch (event.key) {
|
|
458
|
+
case 'PageDown':
|
|
459
|
+
case 'ArrowDown':
|
|
460
|
+
this.setPage(Math.min(this.totalPages, this.currentPage + 1));
|
|
461
|
+
event.preventDefault();
|
|
462
|
+
break;
|
|
463
|
+
case 'PageUp':
|
|
464
|
+
case 'ArrowUp':
|
|
465
|
+
this.setPage(Math.max(1, this.currentPage - 1));
|
|
466
|
+
event.preventDefault();
|
|
467
|
+
break;
|
|
468
|
+
case 'Home':
|
|
469
|
+
this.setPage(1);
|
|
470
|
+
event.preventDefault();
|
|
471
|
+
break;
|
|
472
|
+
case 'End':
|
|
473
|
+
this.setPage(this.totalPages);
|
|
474
|
+
event.preventDefault();
|
|
475
|
+
break;
|
|
476
|
+
case '+':
|
|
477
|
+
case '=':
|
|
478
|
+
this.setZoom(this.currentScale * 1.2);
|
|
479
|
+
event.preventDefault();
|
|
480
|
+
break;
|
|
481
|
+
case '-':
|
|
482
|
+
this.setZoom(this.currentScale / 1.2);
|
|
483
|
+
event.preventDefault();
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// =============================================================================
|
|
488
|
+
// PDF Management
|
|
489
|
+
// =============================================================================
|
|
490
|
+
async loadPDF(source) {
|
|
491
|
+
if (!this.isInitialized) {
|
|
492
|
+
throw new Error('Viewer must be initialized before loading PDF');
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
// Load document with PDF engine
|
|
496
|
+
await this.pdfEngine.loadDocument(source);
|
|
497
|
+
const docInfo = this.pdfEngine.getDocumentInfo();
|
|
498
|
+
this.totalPages = docInfo.numPages;
|
|
499
|
+
// Update viewport manager with total pages
|
|
500
|
+
this.viewportManager.setTotalPages(this.totalPages);
|
|
501
|
+
// Create page containers
|
|
502
|
+
await this.createPageContainers();
|
|
503
|
+
// Load initial pages
|
|
504
|
+
await this.loadInitialPages();
|
|
505
|
+
this.emit('pdfLoaded', { totalPages: this.totalPages });
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
this.emit('error', { type: 'pdf-load-error', error });
|
|
509
|
+
throw error;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Create DOM containers for all pages with real PDF dimensions
|
|
514
|
+
*/
|
|
515
|
+
async createPageContainers() {
|
|
516
|
+
if (!this.pdfContainer)
|
|
517
|
+
return;
|
|
518
|
+
// Clear existing containers
|
|
519
|
+
this.pdfContainer.innerHTML = '';
|
|
520
|
+
this.pageContainers.clear();
|
|
521
|
+
// Get dimensions for the first page to estimate all others
|
|
522
|
+
let avgPageHeight = this.defaultPageHeight;
|
|
523
|
+
try {
|
|
524
|
+
const firstPageDimensions = await this.getPageDimensions(1);
|
|
525
|
+
avgPageHeight = firstPageDimensions.height;
|
|
526
|
+
}
|
|
527
|
+
catch (_error) {
|
|
528
|
+
console.warn('Could not get first page dimensions, using default');
|
|
529
|
+
}
|
|
530
|
+
for (let pageNumber = 1; pageNumber <= this.totalPages; pageNumber++) {
|
|
531
|
+
const pageContainer = document.createElement('div');
|
|
532
|
+
pageContainer.className = 'pdf-page-container';
|
|
533
|
+
pageContainer.setAttribute('data-page-number', pageNumber.toString());
|
|
534
|
+
pageContainer.style.marginBottom = '20px';
|
|
535
|
+
pageContainer.style.position = 'relative';
|
|
536
|
+
// Try to set real dimensions, or use estimated height
|
|
537
|
+
try {
|
|
538
|
+
const dimensions = await this.getPageDimensions(pageNumber);
|
|
539
|
+
pageContainer.style.height = `${dimensions.height}px`;
|
|
540
|
+
pageContainer.style.width = `${dimensions.width}px`;
|
|
541
|
+
}
|
|
542
|
+
catch (_error) {
|
|
543
|
+
// Use average height as fallback
|
|
544
|
+
pageContainer.style.height = `${avgPageHeight}px`;
|
|
545
|
+
}
|
|
546
|
+
this.pdfContainer.appendChild(pageContainer);
|
|
547
|
+
this.pageContainers.set(pageNumber, pageContainer);
|
|
548
|
+
}
|
|
549
|
+
// Update viewport manager with real page dimensions
|
|
550
|
+
this.viewportManager.updateDimensions(this.container?.clientHeight || 600, avgPageHeight);
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Load initial pages (simple and reliable)
|
|
554
|
+
*/
|
|
555
|
+
async loadInitialPages() {
|
|
556
|
+
if (!this.container)
|
|
557
|
+
return;
|
|
558
|
+
// Get what should be visible at the top
|
|
559
|
+
const visiblePages = this.viewportManager.getVisiblePages(0, this.container.clientHeight);
|
|
560
|
+
// Load first page immediately
|
|
561
|
+
try {
|
|
562
|
+
await this.renderPage(1);
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
console.error('Failed to load initial page:', error);
|
|
566
|
+
}
|
|
567
|
+
// Load other visible pages with small delay
|
|
568
|
+
setTimeout(() => {
|
|
569
|
+
visiblePages
|
|
570
|
+
.filter((page) => page > 1)
|
|
571
|
+
.forEach((pageNumber) => {
|
|
572
|
+
this.renderPage(pageNumber).catch((error) => {
|
|
573
|
+
console.error(`Failed to load initial page ${pageNumber}:`, error);
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
}, 100);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Render a specific page
|
|
580
|
+
*/
|
|
581
|
+
async renderPage(pageNumber) {
|
|
582
|
+
const pageContainer = this.pageContainers.get(pageNumber);
|
|
583
|
+
if (!pageContainer)
|
|
584
|
+
return;
|
|
585
|
+
// Check if already rendered
|
|
586
|
+
if (pageContainer.classList.contains('rendered')) {
|
|
587
|
+
console.log(`Page ${pageNumber}: skipping, already has 'rendered' class`);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
console.log(`Page ${pageNumber}: rendering at scale ${this.currentScale}`);
|
|
591
|
+
try {
|
|
592
|
+
// Add loading state
|
|
593
|
+
pageContainer.classList.add('loading');
|
|
594
|
+
// Render PDF canvas
|
|
595
|
+
const canvas = await this.pdfEngine.renderPage(pageNumber, this.currentScale);
|
|
596
|
+
// Clear container and add canvas
|
|
597
|
+
pageContainer.innerHTML = '';
|
|
598
|
+
pageContainer.appendChild(canvas);
|
|
599
|
+
pageContainer.style.height = `${canvas.height}px`;
|
|
600
|
+
pageContainer.style.width = `${canvas.width}px`;
|
|
601
|
+
pageContainer.style.position = 'relative';
|
|
602
|
+
// Add text layer for selection (must be added before highlights)
|
|
603
|
+
await this.addTextLayerToPage(pageNumber);
|
|
604
|
+
// Add simple highlight overlays
|
|
605
|
+
await this.addHighlightsToPage(pageNumber, canvas.width, canvas.height);
|
|
606
|
+
// Mark as rendered and remove loading state
|
|
607
|
+
pageContainer.classList.add('rendered');
|
|
608
|
+
pageContainer.classList.remove('loading');
|
|
609
|
+
this.emit('renderComplete', {
|
|
610
|
+
pageNumber,
|
|
611
|
+
renderTime: 0,
|
|
612
|
+
highlightCount: this.getHighlightCountForPage(pageNumber),
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
pageContainer.classList.remove('loading');
|
|
617
|
+
console.error(`Failed to render page ${pageNumber}:`, error);
|
|
618
|
+
this.emit('renderError', { pageNumber, error });
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
preloadPages(pageNumbers) {
|
|
622
|
+
return Promise.all(pageNumbers.map((pageNumber) => this.renderPage(pageNumber))).then(() => {
|
|
623
|
+
return;
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
setPage(pageNumber) {
|
|
627
|
+
if (pageNumber < 1 || pageNumber > this.totalPages)
|
|
628
|
+
return;
|
|
629
|
+
const pagePosition = this.viewportManager.getScrollPositionForPage(pageNumber);
|
|
630
|
+
if (this.container) {
|
|
631
|
+
this.container.scrollTop = pagePosition;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
getZoom() {
|
|
635
|
+
return this.currentScale;
|
|
636
|
+
}
|
|
637
|
+
setZoom(scale) {
|
|
638
|
+
const previousScale = this.currentScale;
|
|
639
|
+
this.currentScale = Math.max(0.5, Math.min(5.0, scale));
|
|
640
|
+
// Re-render visible pages with new scale
|
|
641
|
+
this.reRenderVisiblePages();
|
|
642
|
+
this.emit('zoomChanged', { scale: this.currentScale, previousScale });
|
|
643
|
+
}
|
|
644
|
+
zoomIn() {
|
|
645
|
+
this.setZoom(this.currentScale * 1.2);
|
|
646
|
+
}
|
|
647
|
+
zoomOut() {
|
|
648
|
+
this.setZoom(this.currentScale / 1.2);
|
|
649
|
+
}
|
|
650
|
+
resetZoom() {
|
|
651
|
+
this.setZoom(1.5);
|
|
652
|
+
}
|
|
653
|
+
getCurrentPage() {
|
|
654
|
+
return this.currentPage;
|
|
655
|
+
}
|
|
656
|
+
getTotalPages() {
|
|
657
|
+
return this.totalPages;
|
|
658
|
+
}
|
|
659
|
+
// =============================================================================
|
|
660
|
+
// Text Selection Management
|
|
661
|
+
// =============================================================================
|
|
662
|
+
/**
|
|
663
|
+
* Enable text selection functionality
|
|
664
|
+
*/
|
|
665
|
+
enableTextSelection() {
|
|
666
|
+
if (!this.options.enableTextSelection) {
|
|
667
|
+
this.options.enableTextSelection = true;
|
|
668
|
+
// Add text layers to all currently rendered pages
|
|
669
|
+
this.pageContainers.forEach(async (pageContainer, pageNumber) => {
|
|
670
|
+
if (pageContainer.classList.contains('rendered')) {
|
|
671
|
+
await this.addTextLayerToPage(pageNumber);
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
this.emit('textSelectionEnabled');
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Disable text selection functionality
|
|
679
|
+
*/
|
|
680
|
+
disableTextSelection() {
|
|
681
|
+
if (this.options.enableTextSelection) {
|
|
682
|
+
this.options.enableTextSelection = false;
|
|
683
|
+
// Remove text layers from all pages
|
|
684
|
+
this.pageContainers.forEach((pageContainer) => {
|
|
685
|
+
const textLayer = pageContainer.querySelector('.text-layer');
|
|
686
|
+
if (textLayer) {
|
|
687
|
+
textLayer.remove();
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
this.emit('textSelectionDisabled');
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Toggle text selection functionality
|
|
695
|
+
*/
|
|
696
|
+
toggleTextSelection() {
|
|
697
|
+
if (this.options.enableTextSelection) {
|
|
698
|
+
this.disableTextSelection();
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
this.enableTextSelection();
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Check if text selection is currently enabled
|
|
708
|
+
*/
|
|
709
|
+
isTextSelectionEnabled() {
|
|
710
|
+
return this.options.enableTextSelection || false;
|
|
711
|
+
}
|
|
712
|
+
// =============================================================================
|
|
713
|
+
// Highlight Management
|
|
714
|
+
// =============================================================================
|
|
715
|
+
loadHighlights(data) {
|
|
716
|
+
if (Array.isArray(data)) {
|
|
717
|
+
this.highlightData = adaptHighlightData(data, {
|
|
718
|
+
categoryResolver: (highlight) => highlight.metadata?.category || 'default',
|
|
719
|
+
termNameResolver: (highlight) => highlight.metadata?.term || highlight.id,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
this.highlightData = data;
|
|
724
|
+
}
|
|
725
|
+
this.updateAnalytics();
|
|
726
|
+
// Update unified layers for all rendered pages
|
|
727
|
+
this.updateAllUnifiedLayers();
|
|
728
|
+
// Update spatial indices
|
|
729
|
+
this.buildAllSpatialIndices();
|
|
730
|
+
this.emit('highlightsLoaded', { data: this.highlightData });
|
|
731
|
+
}
|
|
732
|
+
addHighlight(pageNumber, highlight) {
|
|
733
|
+
// Add to first available category or create 'custom' category
|
|
734
|
+
const categoryKey = Object.keys(this.highlightData)[0] || 'custom';
|
|
735
|
+
if (!this.highlightData[categoryKey]) {
|
|
736
|
+
this.highlightData[categoryKey] = { pages: {}, terms: {} };
|
|
737
|
+
}
|
|
738
|
+
if (!this.highlightData[categoryKey].pages[pageNumber.toString()]) {
|
|
739
|
+
this.highlightData[categoryKey].pages[pageNumber.toString()] = [];
|
|
740
|
+
}
|
|
741
|
+
this.highlightData[categoryKey].pages[pageNumber.toString()].push(highlight);
|
|
742
|
+
// Update page if rendered
|
|
743
|
+
this.updatePageUnifiedLayer(pageNumber);
|
|
744
|
+
this.buildSpatialIndexForPage(pageNumber);
|
|
745
|
+
this.updateAnalytics();
|
|
746
|
+
this.emit('highlightAdded', { pageNumber, highlight });
|
|
747
|
+
}
|
|
748
|
+
removeHighlight(termId) {
|
|
749
|
+
let found = false;
|
|
750
|
+
// Remove from all categories
|
|
751
|
+
Object.keys(this.highlightData).forEach((category) => {
|
|
752
|
+
Object.keys(this.highlightData[category].pages).forEach((pageNumber) => {
|
|
753
|
+
const page = this.highlightData[category].pages[pageNumber];
|
|
754
|
+
const initialLength = page.length;
|
|
755
|
+
this.highlightData[category].pages[pageNumber] = page.filter((highlight) => highlight.termId !== termId);
|
|
756
|
+
if (page.length !== initialLength) {
|
|
757
|
+
found = true;
|
|
758
|
+
this.updatePageUnifiedLayer(parseInt(pageNumber));
|
|
759
|
+
this.buildSpatialIndexForPage(parseInt(pageNumber));
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
// Remove from terms
|
|
763
|
+
delete this.highlightData[category].terms[termId];
|
|
764
|
+
});
|
|
765
|
+
if (found) {
|
|
766
|
+
this.updateAnalytics();
|
|
767
|
+
this.emit('highlightRemoved', { termId });
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
updateHighlightStyle(category, style) {
|
|
771
|
+
this.styleManager.updateCategoryStyle(category, style);
|
|
772
|
+
this.emit('styleUpdated', { category, style });
|
|
773
|
+
}
|
|
774
|
+
getHighlightsForPage(pageNumber) {
|
|
775
|
+
const highlights = [];
|
|
776
|
+
Object.values(this.highlightData).forEach((categoryData) => {
|
|
777
|
+
const pageHighlights = categoryData.pages[pageNumber.toString()];
|
|
778
|
+
if (pageHighlights) {
|
|
779
|
+
highlights.push(...pageHighlights);
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
return highlights;
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Update unified layer for a specific page
|
|
786
|
+
*/
|
|
787
|
+
updatePageUnifiedLayer(pageNumber) {
|
|
788
|
+
const pageContainer = this.pageContainers.get(pageNumber);
|
|
789
|
+
if (!pageContainer)
|
|
790
|
+
return;
|
|
791
|
+
const pageData = this.pdfEngine.getPageData(pageNumber);
|
|
792
|
+
if (!pageData || !pageData.textContent)
|
|
793
|
+
return;
|
|
794
|
+
this.layerBuilder.updateHighlights(this.highlightData, pageNumber, pageData.textContent);
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Update all unified layers
|
|
798
|
+
*/
|
|
799
|
+
updateAllUnifiedLayers() {
|
|
800
|
+
this.pageContainers.forEach((pageContainer, pageNumber) => {
|
|
801
|
+
// Only update pages that are already rendered
|
|
802
|
+
if (pageContainer.classList.contains('rendered')) {
|
|
803
|
+
// Remove existing highlight layer first
|
|
804
|
+
const existingHighlightLayer = pageContainer.querySelector('.highlight-layer');
|
|
805
|
+
if (existingHighlightLayer) {
|
|
806
|
+
existingHighlightLayer.remove();
|
|
807
|
+
}
|
|
808
|
+
// Re-add highlights to already rendered pages
|
|
809
|
+
const canvas = pageContainer.querySelector('canvas');
|
|
810
|
+
if (canvas) {
|
|
811
|
+
this.addHighlightsToPage(pageNumber, canvas.width, canvas.height);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Re-render visible pages (e.g., after zoom change)
|
|
818
|
+
*/
|
|
819
|
+
async reRenderVisiblePages() {
|
|
820
|
+
if (!this.container)
|
|
821
|
+
return;
|
|
822
|
+
console.log('Zoom changed to:', this.currentScale);
|
|
823
|
+
// Clear ALL cached data for the new scale
|
|
824
|
+
this.pdfEngine.clearAllPageCache();
|
|
825
|
+
this.pageDimensions.clear(); // Clear dimension cache
|
|
826
|
+
// Clear all containers and update their sizes for new scale
|
|
827
|
+
let totalHeight = 0;
|
|
828
|
+
let validDimensions = 0;
|
|
829
|
+
for (const [pageNumber, pageContainer] of this.pageContainers) {
|
|
830
|
+
// Clear content
|
|
831
|
+
pageContainer.innerHTML = '';
|
|
832
|
+
pageContainer.classList.remove('rendered');
|
|
833
|
+
// Get new dimensions for the new scale
|
|
834
|
+
try {
|
|
835
|
+
const newDimensions = await this.getPageDimensions(pageNumber);
|
|
836
|
+
pageContainer.style.height = `${newDimensions.height}px`;
|
|
837
|
+
pageContainer.style.width = `${newDimensions.width}px`;
|
|
838
|
+
// Track for average calculation
|
|
839
|
+
totalHeight += newDimensions.height;
|
|
840
|
+
validDimensions++;
|
|
841
|
+
}
|
|
842
|
+
catch (_error) {
|
|
843
|
+
pageContainer.style.height = `${this.defaultPageHeight}px`;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// Calculate new average page height and update viewport manager
|
|
847
|
+
const avgPageHeight = validDimensions > 0 ? totalHeight / validDimensions : this.defaultPageHeight;
|
|
848
|
+
this.viewportManager.updateDimensions(this.container?.clientHeight || 600, avgPageHeight);
|
|
849
|
+
// Render currently visible pages
|
|
850
|
+
const scrollTop = this.container.scrollTop;
|
|
851
|
+
const containerHeight = this.container.clientHeight;
|
|
852
|
+
const visiblePages = this.viewportManager.getVisiblePages(scrollTop, containerHeight);
|
|
853
|
+
console.log('Re-rendering visible pages at new scale:', visiblePages);
|
|
854
|
+
// Render visible pages immediately
|
|
855
|
+
for (const pageNumber of visiblePages) {
|
|
856
|
+
try {
|
|
857
|
+
await this.renderPage(pageNumber);
|
|
858
|
+
}
|
|
859
|
+
catch (error) {
|
|
860
|
+
console.error(`Failed to re-render page ${pageNumber}:`, error);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
// =============================================================================
|
|
865
|
+
// Navigation
|
|
866
|
+
// =============================================================================
|
|
867
|
+
goToHighlight(termId, occurrenceIndex = 0) {
|
|
868
|
+
// Find highlight location
|
|
869
|
+
for (const [, categoryData] of Object.entries(this.highlightData)) {
|
|
870
|
+
for (const [pageNumber, highlights] of Object.entries(categoryData.pages)) {
|
|
871
|
+
const highlight = highlights.find((h) => h.termId === termId);
|
|
872
|
+
if (highlight && highlight.coordinates[occurrenceIndex]) {
|
|
873
|
+
const page = parseInt(pageNumber);
|
|
874
|
+
this.setPage(page);
|
|
875
|
+
// TODO: Scroll to specific coordinates within page
|
|
876
|
+
this.emit('navigationComplete', { termId, pageNumber: page, occurrenceIndex });
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
nextHighlight(category) {
|
|
883
|
+
// TODO: Implement next highlight navigation
|
|
884
|
+
console.log('nextHighlight not yet implemented:', category);
|
|
885
|
+
}
|
|
886
|
+
previousHighlight(category) {
|
|
887
|
+
// TODO: Implement previous highlight navigation
|
|
888
|
+
console.log('previousHighlight not yet implemented:', category);
|
|
889
|
+
}
|
|
890
|
+
goToCoordinate(pageNumber, x, y) {
|
|
891
|
+
this.setPage(pageNumber);
|
|
892
|
+
// TODO: Scroll to specific coordinates
|
|
893
|
+
this.emit('coordinateNavigation', { pageNumber, x, y });
|
|
894
|
+
}
|
|
895
|
+
// =============================================================================
|
|
896
|
+
// Search & Filter
|
|
897
|
+
// =============================================================================
|
|
898
|
+
searchTerms(query) {
|
|
899
|
+
const results = [];
|
|
900
|
+
Object.values(this.highlightData).forEach((categoryData) => {
|
|
901
|
+
Object.values(categoryData.terms).forEach((term) => {
|
|
902
|
+
if (term.term.toLowerCase().includes(query.toLowerCase()) ||
|
|
903
|
+
term.aliases.some((alias) => alias.toLowerCase().includes(query.toLowerCase()))) {
|
|
904
|
+
results.push(term);
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
return results;
|
|
909
|
+
}
|
|
910
|
+
filterByCategory(categories) {
|
|
911
|
+
// TODO: Implement category filtering
|
|
912
|
+
console.log('filterByCategory not yet implemented:', categories);
|
|
913
|
+
}
|
|
914
|
+
highlightSearchResults(query) {
|
|
915
|
+
// TODO: Implement search result highlighting
|
|
916
|
+
console.log('highlightSearchResults not yet implemented:', query);
|
|
917
|
+
}
|
|
918
|
+
clearSearchResults() {
|
|
919
|
+
// TODO: Implement search result clearing
|
|
920
|
+
console.log('clearSearchResults not yet implemented');
|
|
921
|
+
}
|
|
922
|
+
// =============================================================================
|
|
923
|
+
// Interaction Modes
|
|
924
|
+
// =============================================================================
|
|
925
|
+
setInteractionMode(mode) {
|
|
926
|
+
this.interactionHandler.setInteractionMode(mode);
|
|
927
|
+
}
|
|
928
|
+
getInteractionMode() {
|
|
929
|
+
return this.interactionHandler.getInteractionMode();
|
|
930
|
+
}
|
|
931
|
+
// =============================================================================
|
|
932
|
+
// Performance & Analytics
|
|
933
|
+
// =============================================================================
|
|
934
|
+
getPerformanceMetrics() {
|
|
935
|
+
const baseMetrics = this.performanceOptimizer.getPerformanceMetrics();
|
|
936
|
+
// Add memory usage info
|
|
937
|
+
const renderedPages = Array.from(this.pageContainers.entries()).filter(([_, container]) => container.classList.contains('rendered')).length;
|
|
938
|
+
const memoryEstimate = renderedPages * 2; // ~2MB per rendered page
|
|
939
|
+
return {
|
|
940
|
+
...baseMetrics,
|
|
941
|
+
memoryUsage: {
|
|
942
|
+
pages: renderedPages,
|
|
943
|
+
highlights: Object.keys(this.highlightData).length,
|
|
944
|
+
cache: this.pageDimensions.size,
|
|
945
|
+
total: memoryEstimate,
|
|
946
|
+
},
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Get current memory and performance stats
|
|
951
|
+
*/
|
|
952
|
+
getMemoryStats() {
|
|
953
|
+
const renderedPages = Array.from(this.pageContainers.entries()).filter(([_, container]) => container.classList.contains('rendered')).length;
|
|
954
|
+
return {
|
|
955
|
+
renderedPages,
|
|
956
|
+
totalPages: this.totalPages,
|
|
957
|
+
estimatedMemoryMB: renderedPages * 2, // ~2MB per page
|
|
958
|
+
cachedDimensions: this.pageDimensions.size,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
getAnalytics() {
|
|
962
|
+
return { ...this.analytics };
|
|
963
|
+
}
|
|
964
|
+
enableProfiling() {
|
|
965
|
+
// TODO: Enable detailed performance profiling
|
|
966
|
+
console.log('Profiling enabled');
|
|
967
|
+
}
|
|
968
|
+
disableProfiling() {
|
|
969
|
+
// TODO: Disable detailed performance profiling
|
|
970
|
+
console.log('Profiling disabled');
|
|
971
|
+
}
|
|
972
|
+
// =============================================================================
|
|
973
|
+
// Event Management
|
|
974
|
+
// =============================================================================
|
|
975
|
+
addEventListener(event, callback) {
|
|
976
|
+
this.eventListeners.push({ event, callback });
|
|
977
|
+
}
|
|
978
|
+
removeEventListener(event, callback) {
|
|
979
|
+
const index = this.eventListeners.findIndex((listener) => listener.event === event && listener.callback === callback);
|
|
980
|
+
if (index !== -1) {
|
|
981
|
+
this.eventListeners.splice(index, 1);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
emit(event, data) {
|
|
985
|
+
this.eventListeners
|
|
986
|
+
.filter((listener) => listener.event === event)
|
|
987
|
+
.forEach((listener) => {
|
|
988
|
+
try {
|
|
989
|
+
listener.callback(data);
|
|
990
|
+
}
|
|
991
|
+
catch (error) {
|
|
992
|
+
console.error(`Error in event listener for ${event}:`, error);
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
// =============================================================================
|
|
997
|
+
// Utility Methods
|
|
998
|
+
// =============================================================================
|
|
999
|
+
exportAsImage(format = 'png', quality = 0.9) {
|
|
1000
|
+
return new Promise((resolve, reject) => {
|
|
1001
|
+
const canvas = document.createElement('canvas');
|
|
1002
|
+
const ctx = canvas.getContext('2d');
|
|
1003
|
+
if (!ctx || !this.pdfContainer) {
|
|
1004
|
+
reject(new Error('Canvas context not available'));
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
// TODO: Implement image export
|
|
1008
|
+
canvas.toBlob((blob) => {
|
|
1009
|
+
if (blob) {
|
|
1010
|
+
resolve(blob);
|
|
1011
|
+
}
|
|
1012
|
+
else {
|
|
1013
|
+
reject(new Error('Failed to create blob'));
|
|
1014
|
+
}
|
|
1015
|
+
}, `image/${format}`, quality);
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
getViewport() {
|
|
1019
|
+
return {
|
|
1020
|
+
pageNumber: this.currentPage,
|
|
1021
|
+
scale: this.currentScale,
|
|
1022
|
+
scrollTop: this.container?.scrollTop || 0,
|
|
1023
|
+
visibleArea: {
|
|
1024
|
+
x: 0,
|
|
1025
|
+
y: this.container?.scrollTop || 0,
|
|
1026
|
+
width: this.container?.clientWidth || 0,
|
|
1027
|
+
height: this.container?.clientHeight || 0,
|
|
1028
|
+
},
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
refresh() {
|
|
1032
|
+
// Re-render current viewport
|
|
1033
|
+
this.reRenderVisiblePages();
|
|
1034
|
+
this.updateAllUnifiedLayers();
|
|
1035
|
+
this.emit('refreshComplete');
|
|
1036
|
+
}
|
|
1037
|
+
// =============================================================================
|
|
1038
|
+
// Private Helper Methods
|
|
1039
|
+
// =============================================================================
|
|
1040
|
+
/**
|
|
1041
|
+
* Add highlights to a rendered page
|
|
1042
|
+
*/
|
|
1043
|
+
async addHighlightsToPage(pageNumber, canvasWidth, canvasHeight) {
|
|
1044
|
+
const pageContainer = this.pageContainers.get(pageNumber);
|
|
1045
|
+
if (!pageContainer)
|
|
1046
|
+
return;
|
|
1047
|
+
// Check if highlights already added
|
|
1048
|
+
if (pageContainer.querySelector('.highlight-layer')) {
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
// Get the actual PDF page to get proper dimensions
|
|
1052
|
+
try {
|
|
1053
|
+
// Simple approach - just scale coordinates by the current zoom level
|
|
1054
|
+
// The coordinates in the JSON appear to be at scale 1.0
|
|
1055
|
+
const scale = this.currentScale;
|
|
1056
|
+
// Create highlight layer
|
|
1057
|
+
const highlightLayer = document.createElement('div');
|
|
1058
|
+
highlightLayer.className = 'highlight-layer';
|
|
1059
|
+
highlightLayer.style.position = 'absolute';
|
|
1060
|
+
highlightLayer.style.top = '0';
|
|
1061
|
+
highlightLayer.style.left = '0';
|
|
1062
|
+
highlightLayer.style.width = `${canvasWidth}px`;
|
|
1063
|
+
highlightLayer.style.height = `${canvasHeight}px`;
|
|
1064
|
+
highlightLayer.style.zIndex = '2'; // Above text layer
|
|
1065
|
+
highlightLayer.style.pointerEvents = 'none';
|
|
1066
|
+
// Add highlights from each category
|
|
1067
|
+
Object.entries(this.highlightData).forEach(([category, categoryData]) => {
|
|
1068
|
+
const pageHighlights = categoryData.pages[pageNumber.toString()];
|
|
1069
|
+
if (!pageHighlights)
|
|
1070
|
+
return;
|
|
1071
|
+
pageHighlights.forEach((highlight) => {
|
|
1072
|
+
if (highlight.coordinates && highlight.coordinates.length > 0) {
|
|
1073
|
+
highlight.coordinates.forEach((coord) => {
|
|
1074
|
+
const highlightDiv = document.createElement('div');
|
|
1075
|
+
highlightDiv.className = `highlight ${category}-highlight`;
|
|
1076
|
+
highlightDiv.setAttribute('data-term-id', highlight.termId);
|
|
1077
|
+
highlightDiv.setAttribute('data-category', category);
|
|
1078
|
+
// Scale coordinates by current zoom level
|
|
1079
|
+
const left = coord.x1 * scale;
|
|
1080
|
+
const top = coord.y1 * scale;
|
|
1081
|
+
const width = (coord.x2 - coord.x1) * scale;
|
|
1082
|
+
const height = (coord.y2 - coord.y1) * scale;
|
|
1083
|
+
highlightDiv.style.position = 'absolute';
|
|
1084
|
+
highlightDiv.style.left = `${left}px`;
|
|
1085
|
+
highlightDiv.style.top = `${top}px`;
|
|
1086
|
+
highlightDiv.style.width = `${width}px`;
|
|
1087
|
+
highlightDiv.style.height = `${height}px`;
|
|
1088
|
+
highlightDiv.style.backgroundColor = this.getHighlightColor(highlight.termId, category);
|
|
1089
|
+
highlightDiv.style.border = `1px solid ${this.getHighlightColor(highlight.termId, category)}`;
|
|
1090
|
+
highlightDiv.style.pointerEvents = 'auto';
|
|
1091
|
+
highlightDiv.style.cursor = 'pointer';
|
|
1092
|
+
highlightDiv.style.boxSizing = 'border-box';
|
|
1093
|
+
highlightDiv.style.userSelect = 'none'; // Prevent highlight div from being selected
|
|
1094
|
+
highlightDiv.style.mixBlendMode = 'multiply'; // Better color blending
|
|
1095
|
+
// Check for overlapping highlights and adjust opacity
|
|
1096
|
+
const overlappingCount = this.countOverlappingHighlights(highlightLayer, coord, scale);
|
|
1097
|
+
const baseOpacity = Math.max(0.15, 0.3 / Math.max(1, overlappingCount * 0.7));
|
|
1098
|
+
highlightDiv.style.opacity = baseOpacity.toString();
|
|
1099
|
+
// todo make hover opacities configurable?
|
|
1100
|
+
// Add hover effect with dynamic opacity
|
|
1101
|
+
const originalOpacity = baseOpacity.toString();
|
|
1102
|
+
const hoverOpacity = Math.min(0.6, baseOpacity + 0.2).toString();
|
|
1103
|
+
highlightDiv.addEventListener('mouseenter', () => {
|
|
1104
|
+
if (this.options.highlightsConfig?.enableMultilineHover) {
|
|
1105
|
+
const highlightBoxes = highlightLayer.querySelectorAll(`[data-term-id="${highlight.termId}"]`);
|
|
1106
|
+
highlightBoxes.forEach((highlightBox) => {
|
|
1107
|
+
highlightBox.style.opacity = hoverOpacity;
|
|
1108
|
+
});
|
|
1109
|
+
const unhoveredBoxes = highlightLayer.querySelectorAll(`div[data-term-id]:not([data-term-id="${highlight.termId}"])`);
|
|
1110
|
+
unhoveredBoxes.forEach((highlightBox) => {
|
|
1111
|
+
highlightBox.style.opacity = '0.1';
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
else {
|
|
1115
|
+
highlightDiv.style.opacity = hoverOpacity;
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
highlightDiv.addEventListener('mouseleave', () => {
|
|
1119
|
+
if (this.options.highlightsConfig?.enableMultilineHover) {
|
|
1120
|
+
const allBoxes = highlightLayer.querySelectorAll(`div[data-term-id]`);
|
|
1121
|
+
allBoxes.forEach((highlightBox) => {
|
|
1122
|
+
highlightBox.style.opacity = originalOpacity;
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
else {
|
|
1126
|
+
highlightDiv.style.opacity = originalOpacity;
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
highlightLayer.appendChild(highlightDiv);
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
});
|
|
1134
|
+
pageContainer.appendChild(highlightLayer);
|
|
1135
|
+
// Apply selected term highlighting if there's a selected term
|
|
1136
|
+
if (this.selectedTermId) {
|
|
1137
|
+
this.applySelectionToPage(pageNumber);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
catch (error) {
|
|
1141
|
+
console.error(`Failed to add highlights to page ${pageNumber}:`, error);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Update highlights colors for specified page
|
|
1146
|
+
* */
|
|
1147
|
+
updateHighlightsStyles(pageNumber, hoveredIds) {
|
|
1148
|
+
const pageContainer = this.pageContainers.get(pageNumber);
|
|
1149
|
+
if (!pageContainer) {
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const highlightLayer = pageContainer.querySelector('.highlight-layer');
|
|
1153
|
+
if (!highlightLayer) {
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
// Find all highlights in this page
|
|
1157
|
+
const allHighlights = pageContainer.querySelectorAll('.highlight, .highlight-wrapper');
|
|
1158
|
+
allHighlights.forEach((highlight) => {
|
|
1159
|
+
const elementTermId = highlight.getAttribute('data-term-id');
|
|
1160
|
+
const category = highlight.getAttribute('data-category');
|
|
1161
|
+
if (elementTermId && category) {
|
|
1162
|
+
highlight.style.backgroundColor = this.getHighlightColor(elementTermId, category);
|
|
1163
|
+
highlight.style.border =
|
|
1164
|
+
`1px solid ${this.getHighlightColor(elementTermId, category)}`;
|
|
1165
|
+
if (this.options.highlightsConfig?.enableMultilineHover &&
|
|
1166
|
+
hoveredIds &&
|
|
1167
|
+
Array.isArray(hoveredIds)) {
|
|
1168
|
+
const baseOpacity = 0.3;
|
|
1169
|
+
const originalOpacity = baseOpacity.toString();
|
|
1170
|
+
const hoverOpacity = Math.min(0.6, baseOpacity + 0.2).toString();
|
|
1171
|
+
const unhoveredOpacity = '0.1';
|
|
1172
|
+
if (hoveredIds.includes(elementTermId)) {
|
|
1173
|
+
highlight.style.opacity = hoverOpacity;
|
|
1174
|
+
}
|
|
1175
|
+
else if (hoveredIds.length > 0) {
|
|
1176
|
+
highlight.style.opacity = unhoveredOpacity;
|
|
1177
|
+
}
|
|
1178
|
+
else {
|
|
1179
|
+
highlight.style.opacity = originalOpacity;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Get highlight color
|
|
1187
|
+
*/
|
|
1188
|
+
getHighlightColor(termId, category) {
|
|
1189
|
+
if (this.options.highlightsConfig && this.options.highlightsConfig.getHighlightColor) {
|
|
1190
|
+
return this.options.highlightsConfig.getHighlightColor(termId);
|
|
1191
|
+
}
|
|
1192
|
+
return this.getCategoryColor(category);
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Get color for highlight category
|
|
1196
|
+
*/
|
|
1197
|
+
getCategoryColor(category) {
|
|
1198
|
+
const colors = {
|
|
1199
|
+
protein: '#ff6b6b',
|
|
1200
|
+
species: '#4ecdc4',
|
|
1201
|
+
chemical: '#45b7d1',
|
|
1202
|
+
disease: '#f7b731',
|
|
1203
|
+
gene: '#5f27cd',
|
|
1204
|
+
cell_line: '#00d2d3',
|
|
1205
|
+
};
|
|
1206
|
+
return colors[category] || '#666666';
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Build spatial index for a specific page
|
|
1210
|
+
*/
|
|
1211
|
+
buildSpatialIndexForPage(pageNumber) {
|
|
1212
|
+
const highlights = this.getHighlightsForPage(pageNumber);
|
|
1213
|
+
this.performanceOptimizer.buildSpatialIndex(highlights, pageNumber);
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Build spatial indices for all pages
|
|
1217
|
+
*/
|
|
1218
|
+
buildAllSpatialIndices() {
|
|
1219
|
+
for (let pageNumber = 1; pageNumber <= this.totalPages; pageNumber++) {
|
|
1220
|
+
this.buildSpatialIndexForPage(pageNumber);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Get highlight count for a page
|
|
1225
|
+
*/
|
|
1226
|
+
getHighlightCountForPage(pageNumber) {
|
|
1227
|
+
return this.getHighlightsForPage(pageNumber).length;
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Update analytics data
|
|
1231
|
+
*/
|
|
1232
|
+
updateAnalytics() {
|
|
1233
|
+
let totalHighlights = 0;
|
|
1234
|
+
const categoryBreakdown = {};
|
|
1235
|
+
Object.entries(this.highlightData).forEach(([category, categoryData]) => {
|
|
1236
|
+
let categoryCount = 0;
|
|
1237
|
+
Object.values(categoryData.pages).forEach((highlights) => {
|
|
1238
|
+
categoryCount += highlights.length;
|
|
1239
|
+
});
|
|
1240
|
+
categoryBreakdown[category] = categoryCount;
|
|
1241
|
+
totalHighlights += categoryCount;
|
|
1242
|
+
});
|
|
1243
|
+
this.analytics = {
|
|
1244
|
+
...this.analytics,
|
|
1245
|
+
totalHighlights,
|
|
1246
|
+
categoryBreakdown,
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Highlight all instances of a selected term
|
|
1251
|
+
*/
|
|
1252
|
+
highlightSelectedTerm(termId) {
|
|
1253
|
+
if (!this.container)
|
|
1254
|
+
return;
|
|
1255
|
+
// Store the selected term ID for persistence across page renders
|
|
1256
|
+
this.selectedTermId = termId;
|
|
1257
|
+
// Remove previous selection highlighting
|
|
1258
|
+
this.clearSelectedTermHighlighting();
|
|
1259
|
+
// Add selected class to all instances of this term
|
|
1260
|
+
const termElements = this.container.querySelectorAll(`[data-term-id="${termId}"]`);
|
|
1261
|
+
termElements.forEach((element) => {
|
|
1262
|
+
element.classList.add('selected-term');
|
|
1263
|
+
// Override inline styles for selected term
|
|
1264
|
+
const htmlElement = element;
|
|
1265
|
+
htmlElement.style.opacity = '0.75';
|
|
1266
|
+
htmlElement.style.filter = 'brightness(1.05) contrast(1.05) saturate(1.1)';
|
|
1267
|
+
htmlElement.style.boxShadow =
|
|
1268
|
+
'0 0 0 1px rgba(255, 255, 255, 0.6), 0 0 4px rgba(102, 126, 234, 0.3)';
|
|
1269
|
+
htmlElement.style.transform = 'scale(1.02)';
|
|
1270
|
+
htmlElement.style.zIndex = '12';
|
|
1271
|
+
htmlElement.style.borderWidth = '1px';
|
|
1272
|
+
htmlElement.style.transition = 'all 0.3s ease';
|
|
1273
|
+
});
|
|
1274
|
+
// Also dim all other highlights
|
|
1275
|
+
const allHighlights = this.container.querySelectorAll('.highlight, .highlight-wrapper');
|
|
1276
|
+
allHighlights.forEach((element) => {
|
|
1277
|
+
const elementTermId = element.getAttribute('data-term-id');
|
|
1278
|
+
if (!elementTermId || elementTermId !== termId) {
|
|
1279
|
+
element.classList.add('dimmed-highlight');
|
|
1280
|
+
// Override inline styles for dimmed highlights
|
|
1281
|
+
const htmlElement = element;
|
|
1282
|
+
htmlElement.style.opacity = '0.15';
|
|
1283
|
+
htmlElement.style.filter = 'brightness(0.6) contrast(0.7) saturate(0.4) grayscale(0.3)';
|
|
1284
|
+
htmlElement.style.transition = 'all 0.3s ease';
|
|
1285
|
+
}
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Clear selected term highlighting
|
|
1290
|
+
*/
|
|
1291
|
+
clearSelectedTermHighlighting() {
|
|
1292
|
+
if (!this.container)
|
|
1293
|
+
return;
|
|
1294
|
+
// Clear the selected term ID
|
|
1295
|
+
this.selectedTermId = null;
|
|
1296
|
+
const selectedElements = this.container.querySelectorAll('.selected-term');
|
|
1297
|
+
selectedElements.forEach((element) => {
|
|
1298
|
+
element.classList.remove('selected-term');
|
|
1299
|
+
// Reset inline styles for selected elements
|
|
1300
|
+
const htmlElement = element;
|
|
1301
|
+
htmlElement.style.filter = '';
|
|
1302
|
+
htmlElement.style.boxShadow = '';
|
|
1303
|
+
htmlElement.style.transform = '';
|
|
1304
|
+
htmlElement.style.borderWidth = '';
|
|
1305
|
+
// Keep original opacity as it was set by the original rendering
|
|
1306
|
+
});
|
|
1307
|
+
const dimmedElements = this.container.querySelectorAll('.dimmed-highlight');
|
|
1308
|
+
dimmedElements.forEach((element) => {
|
|
1309
|
+
element.classList.remove('dimmed-highlight');
|
|
1310
|
+
// Reset inline styles for dimmed elements
|
|
1311
|
+
const htmlElement = element;
|
|
1312
|
+
htmlElement.style.filter = '';
|
|
1313
|
+
htmlElement.style.transform = '';
|
|
1314
|
+
htmlElement.style.boxShadow = '';
|
|
1315
|
+
htmlElement.style.borderWidth = '';
|
|
1316
|
+
// Restore original opacity from stored value
|
|
1317
|
+
const originalOpacity = htmlElement.dataset.originalOpacity;
|
|
1318
|
+
if (originalOpacity) {
|
|
1319
|
+
htmlElement.style.opacity = originalOpacity;
|
|
1320
|
+
delete htmlElement.dataset.originalOpacity;
|
|
1321
|
+
}
|
|
1322
|
+
else {
|
|
1323
|
+
htmlElement.style.opacity = '0.3'; // Default fallback
|
|
1324
|
+
}
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* Add text layer for text selection functionality
|
|
1329
|
+
*/
|
|
1330
|
+
async addTextLayerToPage(pageNumber) {
|
|
1331
|
+
// Check if text selection is enabled
|
|
1332
|
+
if (!this.options.enableTextSelection) {
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
const pageContainer = this.pageContainers.get(pageNumber);
|
|
1336
|
+
if (!pageContainer)
|
|
1337
|
+
return;
|
|
1338
|
+
// Check if text layer already exists
|
|
1339
|
+
if (pageContainer.querySelector('.text-layer')) {
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
try {
|
|
1343
|
+
// Get the PDF page and its viewport
|
|
1344
|
+
const pdfPage = await this.pdfEngine.getPage(pageNumber);
|
|
1345
|
+
const viewport = pdfPage.getViewport({ scale: this.currentScale });
|
|
1346
|
+
// Extract text content from PDF
|
|
1347
|
+
const textContent = await pdfPage.getTextContent();
|
|
1348
|
+
// Create text layer container
|
|
1349
|
+
const textLayer = document.createElement('div');
|
|
1350
|
+
textLayer.className = 'text-layer';
|
|
1351
|
+
textLayer.style.position = 'absolute';
|
|
1352
|
+
textLayer.style.left = '0';
|
|
1353
|
+
textLayer.style.top = '0';
|
|
1354
|
+
textLayer.style.width = `${viewport.width}px`;
|
|
1355
|
+
textLayer.style.height = `${viewport.height}px`;
|
|
1356
|
+
textLayer.style.overflow = 'hidden';
|
|
1357
|
+
textLayer.style.lineHeight = '1';
|
|
1358
|
+
textLayer.style.zIndex = '1'; // Below highlights but above canvas
|
|
1359
|
+
// textLayer.style.opacity = '0.1'; // Uncomment for debugging text boundaries
|
|
1360
|
+
// Process text items with proper PDF coordinate transformation
|
|
1361
|
+
textContent.items.forEach((item) => {
|
|
1362
|
+
if (!item.str || !item.str.trim())
|
|
1363
|
+
return; // Skip empty text
|
|
1364
|
+
const textSpan = document.createElement('span');
|
|
1365
|
+
textSpan.textContent = item.str;
|
|
1366
|
+
textSpan.style.position = 'absolute';
|
|
1367
|
+
textSpan.style.color = 'transparent'; // Invisible text for selection
|
|
1368
|
+
textSpan.style.backgroundColor = 'transparent'; // No background interference
|
|
1369
|
+
// textSpan.style.border = '1px solid blue'; // Uncomment for debugging boundaries
|
|
1370
|
+
textSpan.style.cursor = 'text';
|
|
1371
|
+
textSpan.style.userSelect = 'text';
|
|
1372
|
+
textSpan.style.whiteSpace = 'pre';
|
|
1373
|
+
textSpan.style.pointerEvents = 'auto';
|
|
1374
|
+
// Use PDF.js transform matrix directly for accurate positioning
|
|
1375
|
+
const transform = item.transform;
|
|
1376
|
+
// Extract position and scaling from transform matrix
|
|
1377
|
+
const scaleX = transform[0];
|
|
1378
|
+
const scaleY = transform[3];
|
|
1379
|
+
const translateX = transform[4];
|
|
1380
|
+
const translateY = transform[5];
|
|
1381
|
+
// Apply viewport transformation to get screen coordinates
|
|
1382
|
+
const [screenX, screenY] = viewport.convertToViewportPoint(translateX, translateY);
|
|
1383
|
+
// Calculate font size with proper viewport scaling
|
|
1384
|
+
const fontSize = Math.abs(scaleY) * this.currentScale;
|
|
1385
|
+
// Calculate text width with proper viewport scaling
|
|
1386
|
+
let textWidth = 0;
|
|
1387
|
+
if (item.width) {
|
|
1388
|
+
// Use PDF.js provided width and apply viewport scaling
|
|
1389
|
+
textWidth = item.width * this.currentScale;
|
|
1390
|
+
}
|
|
1391
|
+
else {
|
|
1392
|
+
// Calculate based on font metrics with scaling
|
|
1393
|
+
const canvas = document.createElement('canvas');
|
|
1394
|
+
const ctx = canvas.getContext('2d');
|
|
1395
|
+
if (ctx) {
|
|
1396
|
+
ctx.font = `${fontSize}px ${item.fontName || 'serif'}`;
|
|
1397
|
+
const metrics = ctx.measureText(item.str);
|
|
1398
|
+
textWidth = metrics.width;
|
|
1399
|
+
}
|
|
1400
|
+
else {
|
|
1401
|
+
// Fallback calculation with scaling
|
|
1402
|
+
textWidth = fontSize * item.str.length * 0.6;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
// Apply horizontal scaling from transform matrix
|
|
1406
|
+
const horizontalScale = Math.abs(scaleX);
|
|
1407
|
+
const verticalScale = Math.abs(scaleY);
|
|
1408
|
+
if (Math.abs(horizontalScale - verticalScale) > 0.01) {
|
|
1409
|
+
const scaleRatio = horizontalScale / verticalScale;
|
|
1410
|
+
textWidth = textWidth * scaleRatio;
|
|
1411
|
+
}
|
|
1412
|
+
// Set position and dimensions using screen coordinates
|
|
1413
|
+
textSpan.style.left = `${screenX}px`;
|
|
1414
|
+
textSpan.style.top = `${screenY - fontSize}px`; // Adjust for text baseline
|
|
1415
|
+
textSpan.style.width = `${textWidth}px`;
|
|
1416
|
+
textSpan.style.height = `${fontSize}px`;
|
|
1417
|
+
textSpan.style.fontSize = `${fontSize}px`;
|
|
1418
|
+
textSpan.style.fontFamily = item.fontName || 'serif';
|
|
1419
|
+
textSpan.style.overflow = 'hidden'; // Prevent text from extending beyond width
|
|
1420
|
+
textSpan.style.textOverflow = 'clip'; // Clip text at boundaries
|
|
1421
|
+
textSpan.style.whiteSpace = 'nowrap'; // Prevent text wrapping
|
|
1422
|
+
textSpan.style.letterSpacing = '0px'; // Remove any letter spacing interference
|
|
1423
|
+
textSpan.style.wordSpacing = '0px'; // Remove word spacing interference
|
|
1424
|
+
// Add data attributes for debugging
|
|
1425
|
+
textSpan.setAttribute('data-text', item.str);
|
|
1426
|
+
textSpan.setAttribute('data-width', textWidth.toString());
|
|
1427
|
+
textSpan.setAttribute('data-original-width', item.width?.toString() || 'unknown');
|
|
1428
|
+
// Handle horizontal scaling if different from vertical
|
|
1429
|
+
if (Math.abs(horizontalScale - verticalScale) > 0.1) {
|
|
1430
|
+
const scaleRatio = horizontalScale / verticalScale;
|
|
1431
|
+
textSpan.style.transform = `scaleX(${scaleRatio})`;
|
|
1432
|
+
textSpan.style.transformOrigin = '0 0';
|
|
1433
|
+
}
|
|
1434
|
+
// Handle rotation if present in transform
|
|
1435
|
+
if (Math.abs(transform[1]) > 0.01 || Math.abs(transform[2]) > 0.01) {
|
|
1436
|
+
const angle = (Math.atan2(transform[1], transform[0]) * 180) / Math.PI;
|
|
1437
|
+
textSpan.style.transform = `rotate(${angle}deg)`;
|
|
1438
|
+
}
|
|
1439
|
+
textLayer.appendChild(textSpan);
|
|
1440
|
+
});
|
|
1441
|
+
pageContainer.appendChild(textLayer);
|
|
1442
|
+
console.log(`Text layer added to page ${pageNumber} with ${textContent.items.length} text items`);
|
|
1443
|
+
console.log('Text span style applied:', textLayer.children[0]?.getAttribute('style'));
|
|
1444
|
+
}
|
|
1445
|
+
catch (error) {
|
|
1446
|
+
console.error(`Failed to add text layer to page ${pageNumber}:`, error);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
/**
|
|
1450
|
+
* Count overlapping highlights at the same coordinates
|
|
1451
|
+
*/
|
|
1452
|
+
countOverlappingHighlights(highlightLayer, coord, scale) {
|
|
1453
|
+
const targetLeft = coord.x1 * scale;
|
|
1454
|
+
const targetTop = coord.y1 * scale;
|
|
1455
|
+
const targetWidth = (coord.x2 - coord.x1) * scale;
|
|
1456
|
+
const targetHeight = (coord.y2 - coord.y1) * scale;
|
|
1457
|
+
const existingHighlights = highlightLayer.children;
|
|
1458
|
+
let overlappingCount = 0;
|
|
1459
|
+
for (let i = 0; i < existingHighlights.length; i++) {
|
|
1460
|
+
const existing = existingHighlights[i];
|
|
1461
|
+
const existingLeft = parseFloat(existing.style.left);
|
|
1462
|
+
const existingTop = parseFloat(existing.style.top);
|
|
1463
|
+
const existingWidth = parseFloat(existing.style.width);
|
|
1464
|
+
const existingHeight = parseFloat(existing.style.height);
|
|
1465
|
+
// Check if highlights overlap significantly (>80% overlap)
|
|
1466
|
+
const overlapLeft = Math.max(targetLeft, existingLeft);
|
|
1467
|
+
const overlapTop = Math.max(targetTop, existingTop);
|
|
1468
|
+
const overlapRight = Math.min(targetLeft + targetWidth, existingLeft + existingWidth);
|
|
1469
|
+
const overlapBottom = Math.min(targetTop + targetHeight, existingTop + existingHeight);
|
|
1470
|
+
if (overlapRight > overlapLeft && overlapBottom > overlapTop) {
|
|
1471
|
+
const overlapArea = (overlapRight - overlapLeft) * (overlapBottom - overlapTop);
|
|
1472
|
+
const targetArea = targetWidth * targetHeight;
|
|
1473
|
+
const overlapPercentage = overlapArea / targetArea;
|
|
1474
|
+
if (overlapPercentage > 0.8) {
|
|
1475
|
+
overlappingCount++;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
return overlappingCount;
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Apply selected term highlighting to a specific page
|
|
1483
|
+
*/
|
|
1484
|
+
applySelectionToPage(pageNumber) {
|
|
1485
|
+
if (!this.selectedTermId)
|
|
1486
|
+
return;
|
|
1487
|
+
const pageContainer = this.pageContainers.get(pageNumber);
|
|
1488
|
+
if (!pageContainer)
|
|
1489
|
+
return;
|
|
1490
|
+
// Find all highlights in this page
|
|
1491
|
+
const allHighlights = pageContainer.querySelectorAll('.highlight, .highlight-wrapper');
|
|
1492
|
+
// Apply styling to all highlights
|
|
1493
|
+
allHighlights.forEach((element) => {
|
|
1494
|
+
const elementTermId = element.getAttribute('data-term-id');
|
|
1495
|
+
const htmlElement = element;
|
|
1496
|
+
if (elementTermId === this.selectedTermId) {
|
|
1497
|
+
// Apply selected styling to matching terms
|
|
1498
|
+
element.classList.add('selected-term');
|
|
1499
|
+
element.classList.remove('dimmed-highlight');
|
|
1500
|
+
htmlElement.style.opacity = '0.75';
|
|
1501
|
+
htmlElement.style.filter = 'brightness(1.05) contrast(1.05) saturate(1.1)';
|
|
1502
|
+
htmlElement.style.boxShadow =
|
|
1503
|
+
'0 0 0 1px rgba(255, 255, 255, 0.6), 0 0 4px rgba(102, 126, 234, 0.3)';
|
|
1504
|
+
htmlElement.style.transform = 'scale(1.02)';
|
|
1505
|
+
htmlElement.style.zIndex = '12';
|
|
1506
|
+
htmlElement.style.borderWidth = '1px';
|
|
1507
|
+
htmlElement.style.transition = 'all 0.3s ease';
|
|
1508
|
+
}
|
|
1509
|
+
else {
|
|
1510
|
+
// Apply dimmed styling to non-selected highlights
|
|
1511
|
+
element.classList.add('dimmed-highlight');
|
|
1512
|
+
element.classList.remove('selected-term');
|
|
1513
|
+
// Store original opacity to restore later
|
|
1514
|
+
if (!htmlElement.dataset.originalOpacity) {
|
|
1515
|
+
htmlElement.dataset.originalOpacity = htmlElement.style.opacity || '0.3';
|
|
1516
|
+
}
|
|
1517
|
+
htmlElement.style.opacity = '0.15';
|
|
1518
|
+
htmlElement.style.filter = 'brightness(0.6) contrast(0.7) saturate(0.4) grayscale(0.3)';
|
|
1519
|
+
htmlElement.style.transform = '';
|
|
1520
|
+
htmlElement.style.boxShadow = '';
|
|
1521
|
+
htmlElement.style.borderWidth = '';
|
|
1522
|
+
htmlElement.style.transition = 'all 0.3s ease';
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
// =============================================================================
|
|
1527
|
+
// Cleanup
|
|
1528
|
+
// =============================================================================
|
|
1529
|
+
destroy() {
|
|
1530
|
+
// Remove event listeners
|
|
1531
|
+
if (this.scrollListener && this.container) {
|
|
1532
|
+
this.container.removeEventListener('scroll', this.scrollListener);
|
|
1533
|
+
}
|
|
1534
|
+
// Destroy components
|
|
1535
|
+
this.pdfEngine.destroy();
|
|
1536
|
+
this.interactionHandler.destroy();
|
|
1537
|
+
this.performanceOptimizer.destroy();
|
|
1538
|
+
this.styleManager.destroy();
|
|
1539
|
+
// Clear DOM references
|
|
1540
|
+
this.container = null;
|
|
1541
|
+
this.pdfContainer = null;
|
|
1542
|
+
this.pageContainers.clear();
|
|
1543
|
+
// Clear state
|
|
1544
|
+
this.eventListeners = [];
|
|
1545
|
+
this.highlightData = {};
|
|
1546
|
+
this.isInitialized = false;
|
|
1547
|
+
this.emit('destroyed');
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
export default PDFHighlightViewer;
|
|
1551
|
+
//# sourceMappingURL=PDFHighlightViewer.js.map
|