nodebb-plugin-pdf-secure 1.2.7 → 1.2.9

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.
@@ -0,0 +1,2641 @@
1
+ // IIFE to prevent global access to pdfDoc, pdfViewer
2
+ (function () {
3
+ 'use strict';
4
+
5
+ // ============================================
6
+ // CANVAS EXPORT PROTECTION
7
+ // Block toDataURL/toBlob for PDF render canvas only
8
+ // Allows: thumbnails, annotations, other canvases
9
+ // ============================================
10
+ const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
11
+ const originalToBlob = HTMLCanvasElement.prototype.toBlob;
12
+
13
+ HTMLCanvasElement.prototype.toDataURL = function () {
14
+ // Block only main PDF page canvases (inside .page elements in #viewerContainer)
15
+ if (this.closest && this.closest('.page') && this.closest('#viewerContainer')) {
16
+ console.warn('[Security] Canvas toDataURL blocked for PDF page');
17
+ return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; // 1x1 transparent
18
+ }
19
+ return originalToDataURL.apply(this, arguments);
20
+ };
21
+
22
+ HTMLCanvasElement.prototype.toBlob = function (callback) {
23
+ // Block only main PDF page canvases
24
+ if (this.closest && this.closest('.page') && this.closest('#viewerContainer')) {
25
+ console.warn('[Security] Canvas toBlob blocked for PDF page');
26
+ // Return empty blob
27
+ if (callback) callback(new Blob([], { type: 'image/png' }));
28
+ return;
29
+ }
30
+ return originalToBlob.apply(this, arguments);
31
+ };
32
+
33
+ pdfjsLib.GlobalWorkerOptions.workerSrc = '';
34
+
35
+ // State - now private, not accessible from console
36
+ let pdfDoc = null;
37
+ let pdfViewer = null;
38
+ let annotationMode = false;
39
+ let currentTool = null; // null, 'pen', 'highlight', 'eraser'
40
+ let currentColor = '#e81224';
41
+ let currentWidth = 2;
42
+ let isDrawing = false;
43
+ let currentPath = null;
44
+ let currentDrawingPage = null;
45
+ let pathSegments = [];
46
+ let drawRAF = null;
47
+
48
+ // Annotation persistence - stores SVG innerHTML per page
49
+ const annotationsStore = new Map();
50
+ const annotationRotations = new Map(); // tracks rotation when annotations were saved
51
+
52
+ // AbortControllers for annotation layer event listeners (cleanup on re-inject)
53
+ const annotationAbortControllers = new Map(); // pageNum -> AbortController
54
+
55
+ // Undo/Redo history stacks - per page
56
+ // Each entry: {nodes: Node[], rotation: number}
57
+ const undoStacks = new Map(); // pageNum -> [{nodes, rotation}, ...]
58
+ const redoStacks = new Map(); // pageNum -> [{nodes, rotation}, ...]
59
+ const MAX_HISTORY = 30;
60
+
61
+ // Store base dimensions (scale=1.0) for each page - ensures consistent coordinates
62
+ const pageBaseDimensions = new Map();
63
+
64
+ // Current SVG reference for drawing
65
+ let currentSvg = null;
66
+
67
+ // Elements
68
+ const container = document.getElementById('viewerContainer');
69
+ const uploadOverlay = document.getElementById('uploadOverlay');
70
+ const fileInput = document.getElementById('fileInput');
71
+ const sidebar = document.getElementById('sidebar');
72
+ const thumbnailContainer = document.getElementById('thumbnailContainer');
73
+
74
+ // Initialize PDFViewer
75
+ const eventBus = new pdfjsViewer.EventBus();
76
+ const linkService = new pdfjsViewer.PDFLinkService({ eventBus });
77
+
78
+ pdfViewer = new pdfjsViewer.PDFViewer({
79
+ container: container,
80
+ eventBus: eventBus,
81
+ linkService: linkService,
82
+ removePageBorders: true,
83
+ textLayerMode: 2
84
+ });
85
+ linkService.setViewer(pdfViewer);
86
+
87
+ // Track first page render for queue system
88
+ let firstPageRendered = false;
89
+ eventBus.on('pagerendered', function (evt) {
90
+ if (!firstPageRendered && evt.pageNumber === 1) {
91
+ firstPageRendered = true;
92
+ // Notify parent that PDF is fully rendered (for queue system)
93
+ if (window.parent && window.parent !== window) {
94
+ const config = window.PDF_SECURE_CONFIG || {};
95
+ window.parent.postMessage({ type: 'pdf-secure-ready', filename: config.filename }, window.location.origin);
96
+ console.log('[PDF-Secure] First page rendered, notifying parent');
97
+ }
98
+ }
99
+ });
100
+
101
+ // File Handling
102
+ document.getElementById('dropzone').onclick = () => fileInput.click();
103
+
104
+ fileInput.onchange = async (e) => {
105
+ const file = e.target.files[0];
106
+ if (file) await loadPDF(file);
107
+ };
108
+
109
+ uploadOverlay.ondragover = (e) => e.preventDefault();
110
+ uploadOverlay.ondrop = async (e) => {
111
+ e.preventDefault();
112
+ const file = e.dataTransfer.files[0];
113
+ if (file?.type === 'application/pdf') await loadPDF(file);
114
+ };
115
+
116
+ async function loadPDF(file) {
117
+ uploadOverlay.classList.add('hidden');
118
+
119
+ const data = await file.arrayBuffer();
120
+ pdfDoc = await pdfjsLib.getDocument({ data }).promise;
121
+
122
+ pdfViewer.setDocument(pdfDoc);
123
+ linkService.setDocument(pdfDoc);
124
+
125
+ ['zoomIn', 'zoomOut', 'pageInput', 'rotateLeft', 'rotateRight'].forEach(id => {
126
+ document.getElementById(id).disabled = false;
127
+ });
128
+
129
+ // Thumbnails will be generated on-demand when sidebar opens
130
+ }
131
+
132
+ // Load PDF from ArrayBuffer (for secure nonce-based loading)
133
+ async function loadPDFFromBuffer(arrayBuffer) {
134
+ uploadOverlay.classList.add('hidden');
135
+
136
+ pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
137
+
138
+ pdfViewer.setDocument(pdfDoc);
139
+ linkService.setDocument(pdfDoc);
140
+
141
+ ['zoomIn', 'zoomOut', 'pageInput', 'rotateLeft', 'rotateRight'].forEach(id => {
142
+ document.getElementById(id).disabled = false;
143
+ });
144
+
145
+ // Thumbnails will be generated on-demand when sidebar opens
146
+ }
147
+
148
+ // Partial XOR decoder - must match backend encoding
149
+ function partialXorDecode(encodedData, keyBase64) {
150
+ const key = Uint8Array.from(atob(keyBase64), c => c.charCodeAt(0));
151
+ const data = new Uint8Array(encodedData);
152
+ const keyLen = key.length;
153
+
154
+ // Decrypt first 10KB fully
155
+ const fullDecryptLen = Math.min(10240, data.length);
156
+ for (let i = 0; i < fullDecryptLen; i++) {
157
+ data[i] = data[i] ^ key[i % keyLen];
158
+ }
159
+
160
+ // Decrypt every 50th byte after that
161
+ for (let i = fullDecryptLen; i < data.length; i += 50) {
162
+ data[i] = data[i] ^ key[i % keyLen];
163
+ }
164
+
165
+ return data.buffer;
166
+ }
167
+
168
+ // Auto-load PDF if config is present (injected by NodeBB plugin)
169
+ async function autoLoadSecurePDF() {
170
+ if (!window.PDF_SECURE_CONFIG || !window.PDF_SECURE_CONFIG.filename) {
171
+ console.log('[PDF-Secure] No config found, showing file picker');
172
+ return;
173
+ }
174
+
175
+ const config = window.PDF_SECURE_CONFIG;
176
+ console.log('[PDF-Secure] Auto-loading:', config.filename);
177
+
178
+ // Show loading state
179
+ const dropzone = document.getElementById('dropzone');
180
+ if (dropzone) {
181
+ dropzone.innerHTML = `
182
+ <svg viewBox="0 0 24 24" class="spin">
183
+ <path d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8z" />
184
+ </svg>
185
+ <h2>PDF Yükleniyor...</h2>
186
+ <p>${config.filename}</p>
187
+ `;
188
+ }
189
+
190
+ try {
191
+ // ============================================
192
+ // SPA CACHE - Check if parent has cached buffer
193
+ // ============================================
194
+ let pdfBuffer = null;
195
+
196
+ if (window.parent && window.parent !== window) {
197
+ // Request cached buffer from parent
198
+ const cachePromise = new Promise((resolve) => {
199
+ const handler = (event) => {
200
+ if (event.data && event.data.type === 'pdf-secure-cache-response' && event.data.filename === config.filename) {
201
+ window.removeEventListener('message', handler);
202
+ resolve(event.data.buffer);
203
+ }
204
+ };
205
+ window.addEventListener('message', handler);
206
+
207
+ // Timeout after 100ms
208
+ setTimeout(() => {
209
+ window.removeEventListener('message', handler);
210
+ resolve(null);
211
+ }, 100);
212
+
213
+ window.parent.postMessage({ type: 'pdf-secure-cache-request', filename: config.filename }, window.location.origin);
214
+ });
215
+
216
+ pdfBuffer = await cachePromise;
217
+ if (pdfBuffer) {
218
+ console.log('[PDF-Secure] Using cached buffer');
219
+ }
220
+ }
221
+
222
+ // If no cache, fetch from server
223
+ if (!pdfBuffer) {
224
+ // Nonce and key are embedded in HTML config (not fetched from API)
225
+ const nonce = config.nonce;
226
+ const xorKey = config.dk;
227
+
228
+ // Fetch encrypted PDF binary
229
+ const pdfUrl = config.relativePath + '/api/v3/plugins/pdf-secure/pdf-data?nonce=' + encodeURIComponent(nonce);
230
+ const pdfRes = await fetch(pdfUrl, { credentials: 'same-origin' });
231
+
232
+ if (!pdfRes.ok) {
233
+ throw new Error('PDF yüklenemedi (' + pdfRes.status + ')');
234
+ }
235
+
236
+ const encodedBuffer = await pdfRes.arrayBuffer();
237
+ console.log('[PDF-Secure] Encrypted data received:', encodedBuffer.byteLength, 'bytes');
238
+
239
+ // Decode XOR encrypted data
240
+ if (xorKey) {
241
+ console.log('[PDF-Secure] Decoding XOR encrypted data...');
242
+ pdfBuffer = partialXorDecode(encodedBuffer, xorKey);
243
+ } else {
244
+ pdfBuffer = encodedBuffer;
245
+ }
246
+
247
+ // Send buffer to parent for caching
248
+ if (window.parent && window.parent !== window) {
249
+ // Clone buffer for parent (we keep original)
250
+ const bufferCopy = pdfBuffer.slice(0);
251
+ window.parent.postMessage({
252
+ type: 'pdf-secure-buffer',
253
+ filename: config.filename,
254
+ buffer: bufferCopy
255
+ }, window.location.origin, [bufferCopy]); // Transferable
256
+ }
257
+ }
258
+
259
+ console.log('[PDF-Secure] PDF decoded successfully');
260
+
261
+ // Step 4: Load into viewer
262
+ await loadPDFFromBuffer(pdfBuffer);
263
+
264
+ // Step 5: Moved to pagerendered event for proper timing
265
+
266
+ // Step 6: Security - clear references to prevent extraction
267
+ pdfBuffer = null;
268
+
269
+ // Security: Delete config containing sensitive data (nonce, key)
270
+ delete window.PDF_SECURE_CONFIG;
271
+
272
+ // Security: Remove PDF.js globals to prevent console manipulation
273
+ delete window.pdfjsLib;
274
+ delete window.pdfjsViewer;
275
+
276
+ // Security: Block dangerous PDF.js methods
277
+ if (pdfDoc) {
278
+ pdfDoc.getData = function () {
279
+ console.warn('[Security] getData() is blocked');
280
+ return Promise.reject(new Error('Access denied'));
281
+ };
282
+ pdfDoc.saveDocument = function () {
283
+ console.warn('[Security] saveDocument() is blocked');
284
+ return Promise.reject(new Error('Access denied'));
285
+ };
286
+ }
287
+
288
+ console.log('[PDF-Secure] PDF fully loaded and ready');
289
+
290
+ } catch (err) {
291
+ console.error('[PDF-Secure] Auto-load error:', err);
292
+
293
+ // Notify parent of error (prevents 60s queue hang)
294
+ if (window.parent && window.parent !== window) {
295
+ const config = window.PDF_SECURE_CONFIG || {};
296
+ window.parent.postMessage({
297
+ type: 'pdf-secure-ready',
298
+ filename: config.filename,
299
+ error: err.message
300
+ }, window.location.origin);
301
+ }
302
+
303
+ if (dropzone) {
304
+ dropzone.innerHTML = `
305
+ <svg viewBox="0 0 24 24" style="fill: #e81224;">
306
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
307
+ </svg>
308
+ <h2>Hata</h2>
309
+ <p>${err.message}</p>
310
+ `;
311
+ }
312
+ }
313
+ }
314
+
315
+ // Run auto-load on page ready
316
+ autoLoadSecurePDF();
317
+
318
+ // Generate Thumbnails (deferred - only when sidebar opens, lazy-rendered)
319
+ let thumbnailsGenerated = false;
320
+ async function generateThumbnails() {
321
+ if (thumbnailsGenerated) return;
322
+ thumbnailsGenerated = true;
323
+ thumbnailContainer.innerHTML = '';
324
+
325
+ // Create placeholder thumbnails for all pages
326
+ for (let i = 1; i <= pdfDoc.numPages; i++) {
327
+ const thumb = document.createElement('div');
328
+ thumb.className = 'thumbnail' + (i === 1 ? ' active' : '');
329
+ thumb.dataset.page = i;
330
+ thumb.innerHTML = `<div class="thumbnailNum">${i}</div>`;
331
+ thumb.onclick = () => {
332
+ pdfViewer.currentPageNumber = i;
333
+ document.querySelectorAll('.thumbnail').forEach(t => t.classList.remove('active'));
334
+ thumb.classList.add('active');
335
+ };
336
+ thumbnailContainer.appendChild(thumb);
337
+ }
338
+
339
+ // Lazy render thumbnails with IntersectionObserver
340
+ const thumbObserver = new IntersectionObserver((entries) => {
341
+ entries.forEach(async (entry) => {
342
+ if (entry.isIntersecting && !entry.target.dataset.rendered) {
343
+ entry.target.dataset.rendered = 'true';
344
+ const pageNum = parseInt(entry.target.dataset.page);
345
+ const page = await pdfDoc.getPage(pageNum);
346
+ const viewport = page.getViewport({ scale: 0.2 });
347
+ const canvas = document.createElement('canvas');
348
+ canvas.width = viewport.width;
349
+ canvas.height = viewport.height;
350
+ await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
351
+ entry.target.insertBefore(canvas, entry.target.firstChild);
352
+ }
353
+ });
354
+ }, { root: thumbnailContainer, rootMargin: '200px' });
355
+
356
+ thumbnailContainer.querySelectorAll('.thumbnail').forEach(t => thumbObserver.observe(t));
357
+ }
358
+
359
+ // Events
360
+ eventBus.on('pagesinit', () => {
361
+ pdfViewer.currentScaleValue = 'page-width';
362
+ document.getElementById('pageCount').textContent = `/ ${pdfViewer.pagesCount}`;
363
+ });
364
+
365
+ eventBus.on('pagechanging', (evt) => {
366
+ document.getElementById('pageInput').value = evt.pageNumber;
367
+ // Update active thumbnail
368
+ document.querySelectorAll('.thumbnail').forEach(t => {
369
+ t.classList.toggle('active', parseInt(t.dataset.page) === evt.pageNumber);
370
+ });
371
+ // Update undo/redo buttons for new page
372
+ updateUndoRedoButtons();
373
+
374
+ // Bug fix: Clear selection on page change (stale SVG reference)
375
+ clearAnnotationSelection();
376
+
377
+ // Bug fix: Reset drawing state on page change
378
+ if (isDrawing && currentDrawingPage) {
379
+ saveAnnotations(currentDrawingPage);
380
+ }
381
+ isDrawing = false;
382
+ currentPath = null;
383
+ currentSvg = null;
384
+ currentDrawingPage = null;
385
+ });
386
+
387
+ eventBus.on('pagerendered', (evt) => {
388
+ injectAnnotationLayer(evt.pageNumber);
389
+
390
+ // Rotation is handled natively by PDF.js via pagesRotation
391
+ });
392
+
393
+ // Page Navigation
394
+ document.getElementById('pageInput').onchange = (e) => {
395
+ const num = parseInt(e.target.value);
396
+ if (num >= 1 && num <= pdfViewer.pagesCount) {
397
+ pdfViewer.currentPageNumber = num;
398
+ }
399
+ };
400
+
401
+ // Zoom
402
+ document.getElementById('zoomIn').onclick = () => pdfViewer.currentScale += 0.25;
403
+ document.getElementById('zoomOut').onclick = () => pdfViewer.currentScale -= 0.25;
404
+
405
+ // Sidebar toggle (deferred thumbnail generation)
406
+ const sidebarEl = document.getElementById('sidebar');
407
+ const sidebarBtnEl = document.getElementById('sidebarBtn');
408
+ const closeSidebarBtn = document.getElementById('closeSidebar');
409
+
410
+ sidebarBtnEl.onclick = () => {
411
+ const isOpening = !sidebarEl.classList.contains('open');
412
+ sidebarEl.classList.toggle('open');
413
+ sidebarBtnEl.classList.toggle('active');
414
+ container.classList.toggle('withSidebar', sidebarEl.classList.contains('open'));
415
+
416
+ // Generate thumbnails on first open (deferred loading)
417
+ if (isOpening && pdfDoc) {
418
+ generateThumbnails();
419
+ }
420
+ };
421
+
422
+ closeSidebarBtn.onclick = () => {
423
+ sidebarEl.classList.remove('open');
424
+ sidebarBtnEl.classList.remove('active');
425
+ container.classList.remove('withSidebar');
426
+ };
427
+
428
+ // Sepia Reading Mode
429
+ let sepiaMode = false;
430
+ document.getElementById('sepiaBtn').onclick = () => {
431
+ sepiaMode = !sepiaMode;
432
+ document.getElementById('viewer').classList.toggle('sepia', sepiaMode);
433
+ container.classList.toggle('sepia', sepiaMode);
434
+ document.getElementById('sepiaBtn').classList.toggle('active', sepiaMode);
435
+ };
436
+
437
+ // Page Rotation — uses PDF.js native rotation (re-renders at correct size & quality)
438
+ function rotatePage(delta) {
439
+ const current = pdfViewer.pagesRotation || 0;
440
+ // Clear cached dimensions so they get recalculated with new rotation
441
+ pageBaseDimensions.clear();
442
+ pdfViewer.pagesRotation = (current + delta + 360) % 360;
443
+ }
444
+
445
+ document.getElementById('rotateLeft').onclick = () => rotatePage(-90);
446
+ document.getElementById('rotateRight').onclick = () => rotatePage(90);
447
+
448
+
449
+
450
+
451
+ // Tool settings - separate for each tool
452
+ let highlightColor = '#fff100';
453
+ let highlightWidth = 4;
454
+ let drawColor = '#e81224';
455
+ let drawWidth = 2;
456
+ let shapeColor = '#e81224';
457
+ let shapeWidth = 2;
458
+ let currentShape = 'rectangle'; // rectangle, circle, line, arrow
459
+
460
+ // Dropdown Panel Logic
461
+ const highlightDropdown = document.getElementById('highlightDropdown');
462
+ const drawDropdown = document.getElementById('drawDropdown');
463
+ const shapesDropdown = document.getElementById('shapesDropdown');
464
+ const highlightWrapper = document.getElementById('highlightWrapper');
465
+ const drawWrapper = document.getElementById('drawWrapper');
466
+ const shapesWrapper = document.getElementById('shapesWrapper');
467
+
468
+ const dropdownBackdrop = document.getElementById('dropdownBackdrop');
469
+ const overflowDropdown = document.getElementById('overflowDropdown');
470
+
471
+ function closeAllDropdowns() {
472
+ highlightDropdown.classList.remove('visible');
473
+ drawDropdown.classList.remove('visible');
474
+ shapesDropdown.classList.remove('visible');
475
+ overflowDropdown.classList.remove('visible');
476
+ dropdownBackdrop.classList.remove('visible');
477
+ }
478
+
479
+ function toggleDropdown(dropdown, e) {
480
+ e.stopPropagation();
481
+ const isVisible = dropdown.classList.contains('visible');
482
+ closeAllDropdowns();
483
+ if (!isVisible) {
484
+ const useBottomSheet = isMobile() || isTabletPortrait();
485
+ // Add drag handle for mobile/tablet portrait bottom sheets
486
+ if (useBottomSheet && !dropdown.querySelector('.bottomSheetHandle')) {
487
+ const handle = document.createElement('div');
488
+ handle.className = 'bottomSheetHandle';
489
+ dropdown.insertBefore(handle, dropdown.firstChild);
490
+ }
491
+ dropdown.classList.add('visible');
492
+ // Show backdrop on mobile/tablet portrait
493
+ if (useBottomSheet) {
494
+ dropdownBackdrop.classList.add('visible');
495
+ }
496
+ }
497
+ }
498
+
499
+ // Backdrop click closes dropdowns
500
+ dropdownBackdrop.addEventListener('click', () => {
501
+ closeAllDropdowns();
502
+ });
503
+
504
+ // Arrow buttons toggle dropdowns
505
+ document.getElementById('highlightArrow').onclick = (e) => toggleDropdown(highlightDropdown, e);
506
+ document.getElementById('drawArrow').onclick = (e) => toggleDropdown(drawDropdown, e);
507
+ document.getElementById('shapesArrow').onclick = (e) => toggleDropdown(shapesDropdown, e);
508
+
509
+ // Overflow menu toggle
510
+ document.getElementById('overflowBtn').onclick = (e) => toggleDropdown(overflowDropdown, e);
511
+ overflowDropdown.onclick = (e) => e.stopPropagation();
512
+
513
+ // Overflow menu actions
514
+ document.getElementById('overflowRotateLeft').onclick = () => {
515
+ rotatePage(-90);
516
+ closeAllDropdowns();
517
+ };
518
+ document.getElementById('overflowRotateRight').onclick = () => {
519
+ rotatePage(90);
520
+ closeAllDropdowns();
521
+ };
522
+ document.getElementById('overflowSepia').onclick = () => {
523
+ document.getElementById('sepiaBtn').click();
524
+ document.getElementById('overflowSepia').classList.toggle('active',
525
+ document.getElementById('sepiaBtn').classList.contains('active'));
526
+ closeAllDropdowns();
527
+ };
528
+
529
+ // Close dropdowns when clicking outside
530
+ document.addEventListener('click', (e) => {
531
+ if (!e.target.closest('.toolDropdown') && !e.target.closest('.dropdownArrow')) {
532
+ closeAllDropdowns();
533
+ }
534
+ });
535
+
536
+ // Prevent dropdown from closing when clicking inside
537
+ highlightDropdown.onclick = (e) => e.stopPropagation();
538
+ drawDropdown.onclick = (e) => e.stopPropagation();
539
+ shapesDropdown.onclick = (e) => e.stopPropagation();
540
+
541
+ // Drawing Tools - Toggle Behavior
542
+ async function setTool(tool) {
543
+ // If same tool clicked again, deactivate
544
+ if (currentTool === tool) {
545
+ currentTool = null;
546
+ annotationMode = false;
547
+ document.querySelectorAll('.annotationLayer').forEach(el => el.classList.remove('active'));
548
+ } else {
549
+ currentTool = tool;
550
+ annotationMode = true;
551
+
552
+ // Set color and width based on tool
553
+ if (tool === 'highlight') {
554
+ currentColor = highlightColor;
555
+ currentWidth = highlightWidth;
556
+ } else if (tool === 'pen') {
557
+ currentColor = drawColor;
558
+ currentWidth = drawWidth;
559
+ } else if (tool === 'shape') {
560
+ currentColor = shapeColor;
561
+ currentWidth = shapeWidth;
562
+ }
563
+
564
+ // Activate annotation layers (no re-inject needed, layers persist)
565
+ document.querySelectorAll('.annotationLayer').forEach(layer => {
566
+ layer.classList.add('active');
567
+ });
568
+ }
569
+
570
+ // Update button states
571
+ highlightWrapper.classList.toggle('active', currentTool === 'highlight');
572
+ drawWrapper.classList.toggle('active', currentTool === 'pen');
573
+ shapesWrapper.classList.toggle('active', currentTool === 'shape');
574
+ document.getElementById('eraserBtn').classList.toggle('active', currentTool === 'eraser');
575
+ document.getElementById('textBtn').classList.toggle('active', currentTool === 'text');
576
+ document.getElementById('selectBtn').classList.toggle('active', currentTool === 'select');
577
+
578
+ // Toggle select-mode class on annotation layers
579
+ document.querySelectorAll('.annotationLayer').forEach(layer => {
580
+ layer.classList.toggle('select-mode', currentTool === 'select');
581
+ });
582
+
583
+ // Clear selection when switching tools
584
+ if (currentTool !== 'select') {
585
+ clearAnnotationSelection();
586
+ }
587
+ }
588
+
589
+ document.getElementById('drawBtn').onclick = () => setTool('pen');
590
+ document.getElementById('highlightBtn').onclick = () => setTool('highlight');
591
+ document.getElementById('shapesBtn').onclick = () => setTool('shape');
592
+ document.getElementById('eraserBtn').onclick = () => setTool('eraser');
593
+ document.getElementById('textBtn').onclick = () => setTool('text');
594
+ document.getElementById('selectBtn').onclick = () => setTool('select');
595
+
596
+ // Undo / Redo / Clear All
597
+ document.getElementById('undoBtn').onclick = () => performUndo();
598
+ document.getElementById('redoBtn').onclick = () => performRedo();
599
+ document.getElementById('clearAllBtn').onclick = () => performClearAll();
600
+
601
+ // Color picker event delegation
602
+ function setupColorPicker(containerId, onColorChange) {
603
+ document.getElementById(containerId).addEventListener('click', (e) => {
604
+ const dot = e.target.closest('.colorDot');
605
+ if (!dot) return;
606
+ e.stopPropagation();
607
+ e.currentTarget.querySelectorAll('.colorDot').forEach(d => d.classList.remove('active'));
608
+ dot.classList.add('active');
609
+ onColorChange(dot.dataset.color);
610
+ });
611
+ }
612
+ setupColorPicker('highlightColors', c => {
613
+ highlightColor = c;
614
+ if (currentTool === 'highlight') currentColor = c;
615
+ document.getElementById('highlightWave').setAttribute('stroke', c);
616
+ });
617
+ setupColorPicker('drawColors', c => {
618
+ drawColor = c;
619
+ if (currentTool === 'pen') currentColor = c;
620
+ document.getElementById('drawWave').setAttribute('stroke', c);
621
+ });
622
+ setupColorPicker('shapeColors', c => {
623
+ shapeColor = c;
624
+ if (currentTool === 'shape') currentColor = c;
625
+ });
626
+
627
+ // Highlighter Thickness Slider
628
+ document.getElementById('highlightThickness').oninput = (e) => {
629
+ highlightWidth = parseInt(e.target.value);
630
+ if (currentTool === 'highlight') currentWidth = highlightWidth;
631
+ document.getElementById('highlightWave').setAttribute('stroke-width', highlightWidth * 2);
632
+ };
633
+
634
+ // Pen Thickness Slider
635
+ document.getElementById('drawThickness').oninput = (e) => {
636
+ drawWidth = parseInt(e.target.value);
637
+ if (currentTool === 'pen') currentWidth = drawWidth;
638
+ document.getElementById('drawWave').setAttribute('stroke-width', drawWidth);
639
+ };
640
+
641
+ // Shape Selection (event delegation)
642
+ document.querySelector('.shapeBtn')?.closest('.dropdownSection')?.addEventListener('click', (e) => {
643
+ const btn = e.target.closest('.shapeBtn');
644
+ if (!btn) return;
645
+ e.stopPropagation();
646
+ document.querySelectorAll('.shapeBtn').forEach(b => b.classList.remove('active'));
647
+ btn.classList.add('active');
648
+ currentShape = btn.dataset.shape;
649
+ });
650
+
651
+ // Shape Thickness Slider
652
+ document.getElementById('shapeThickness').oninput = (e) => {
653
+ shapeWidth = parseInt(e.target.value);
654
+ if (currentTool === 'shape') currentWidth = shapeWidth;
655
+ };
656
+
657
+ // Annotation Layer with Persistence
658
+ async function injectAnnotationLayer(pageNum) {
659
+ const pageView = pdfViewer.getPageView(pageNum - 1);
660
+ if (!pageView?.div) return;
661
+
662
+ // Remove old SVG and abort its event listeners
663
+ const oldSvg = pageView.div.querySelector('.annotationLayer');
664
+ if (oldSvg) oldSvg.remove();
665
+ const oldController = annotationAbortControllers.get(pageNum);
666
+ if (oldController) oldController.abort();
667
+
668
+ // Get or calculate base dimensions (scale=1.0, current rotation)
669
+ const currentRotation = pdfViewer.pagesRotation || 0;
670
+ let baseDims = pageBaseDimensions.get(pageNum);
671
+ if (!baseDims) {
672
+ const page = await pdfDoc.getPage(pageNum);
673
+ const baseViewport = page.getViewport({ scale: 1.0, rotation: currentRotation });
674
+ baseDims = { width: baseViewport.width, height: baseViewport.height };
675
+ pageBaseDimensions.set(pageNum, baseDims);
676
+ }
677
+
678
+ // Create fresh SVG with viewBox matching rotated dimensions
679
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
680
+ svg.setAttribute('class', 'annotationLayer');
681
+ svg.setAttribute('viewBox', `0 0 ${baseDims.width} ${baseDims.height}`);
682
+ svg.setAttribute('preserveAspectRatio', 'none');
683
+ svg.style.width = '100%';
684
+ svg.style.height = '100%';
685
+ svg.dataset.page = pageNum;
686
+ svg.dataset.viewboxWidth = baseDims.width;
687
+ svg.dataset.viewboxHeight = baseDims.height;
688
+
689
+ pageView.div.appendChild(svg);
690
+
691
+
692
+
693
+ // Restore saved annotations for this page (with rotation transform if needed)
694
+ if (annotationsStore.has(pageNum)) {
695
+ const savedRot = annotationRotations.get(pageNum) || 0;
696
+ const curRot = pdfViewer.pagesRotation || 0;
697
+ const delta = (curRot - savedRot + 360) % 360;
698
+
699
+ if (delta === 0) {
700
+ svg.innerHTML = annotationsStore.get(pageNum);
701
+ } else {
702
+ // Get unrotated page dimensions for transform calculation
703
+ const page = await pdfDoc.getPage(pageNum);
704
+ const unrotVP = page.getViewport({ scale: 1.0 });
705
+ const W = unrotVP.width, H = unrotVP.height;
706
+
707
+ // Old viewBox dimensions (at saved rotation)
708
+ let oW, oH;
709
+ if (savedRot === 90 || savedRot === 270) { oW = H; oH = W; }
710
+ else { oW = W; oH = H; }
711
+
712
+ let transform;
713
+ if (delta === 90) transform = `translate(${oH},0) rotate(90)`;
714
+ else if (delta === 180) transform = `translate(${oW},${oH}) rotate(180)`;
715
+ else if (delta === 270) transform = `translate(0,${oW}) rotate(270)`;
716
+
717
+ svg.innerHTML = `<g transform="${transform}">${annotationsStore.get(pageNum)}</g>`;
718
+
719
+ // Update stored annotations & rotation to current
720
+ annotationsStore.set(pageNum, svg.innerHTML);
721
+ annotationRotations.set(pageNum, curRot);
722
+ // Undo/redo stack entries store their own rotation,
723
+ // so no wrapping needed — transforms applied at restore time
724
+ }
725
+ }
726
+
727
+ // Bug fix: Use AbortController for cleanup when page re-renders
728
+ const controller = new AbortController();
729
+ const signal = controller.signal;
730
+ annotationAbortControllers.set(pageNum, controller);
731
+
732
+ svg.addEventListener('mousedown', (e) => startDraw(e, pageNum), { signal });
733
+ svg.addEventListener('mousemove', draw, { signal });
734
+ svg.addEventListener('mouseup', () => stopDraw(pageNum), { signal });
735
+ svg.addEventListener('mouseleave', () => stopDraw(pageNum), { signal });
736
+
737
+ // Touch support for tablets — passive:false only when annotation tool active
738
+ const touchStartHandler = (e) => {
739
+ if (!currentTool) return;
740
+ e.preventDefault();
741
+ startDraw(e, pageNum);
742
+ };
743
+ const touchMoveHandler = (e) => {
744
+ if (!currentTool) return;
745
+ e.preventDefault();
746
+ draw(e);
747
+ };
748
+ if (annotationMode) {
749
+ svg.addEventListener('touchstart', touchStartHandler, { passive: false, signal });
750
+ svg.addEventListener('touchmove', touchMoveHandler, { passive: false, signal });
751
+ } else {
752
+ svg.addEventListener('touchstart', touchStartHandler, { signal });
753
+ svg.addEventListener('touchmove', touchMoveHandler, { signal });
754
+ }
755
+ svg.addEventListener('touchend', () => stopDraw(pageNum), { signal });
756
+ svg.addEventListener('touchcancel', () => stopDraw(pageNum), { signal });
757
+
758
+ svg.classList.toggle('active', annotationMode);
759
+ }
760
+
761
+ // Strip transient classes, styles, and elements from SVG before saving
762
+ // Works on a clone to avoid modifying live DOM
763
+ function getCleanSvgInnerHTML(svg) {
764
+ const clone = svg.cloneNode(true);
765
+ const marquee = clone.querySelector('.marquee-rect');
766
+ if (marquee) marquee.remove();
767
+
768
+ const transientClasses = ['annotation-selected', 'annotation-multi-selected', 'annotation-dragging', 'just-selected'];
769
+ clone.querySelectorAll('path, rect, ellipse, line, text').forEach(el => {
770
+ transientClasses.forEach(cls => el.classList.remove(cls));
771
+ el.removeAttribute('style');
772
+ if (el.getAttribute('class') === '') el.removeAttribute('class');
773
+ });
774
+
775
+ return clone.innerHTML.trim();
776
+ }
777
+
778
+ // Save annotations for a page (with undo history)
779
+ function saveAnnotations(pageNum) {
780
+ const pageView = pdfViewer.getPageView(pageNum - 1);
781
+ const svg = pageView?.div?.querySelector('.annotationLayer');
782
+ if (!svg) return;
783
+
784
+ // Push previous state to undo stack before saving new state
785
+ const previousRotation = annotationRotations.get(pageNum) || 0;
786
+ const newState = getCleanSvgInnerHTML(svg);
787
+ const previousState = annotationsStore.get(pageNum) || '';
788
+
789
+ // Only push to history if state actually changed
790
+ if (previousState !== newState) {
791
+ if (!undoStacks.has(pageNum)) undoStacks.set(pageNum, []);
792
+ const stack = undoStacks.get(pageNum);
793
+ // Clone previous SVG children for efficient undo
794
+ const prevNodes = [];
795
+ // Parse previous state to get nodes (use a temp container)
796
+ if (previousState) {
797
+ const temp = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
798
+ temp.innerHTML = previousState;
799
+ for (const child of temp.children) prevNodes.push(child.cloneNode(true));
800
+ }
801
+ stack.push({ nodes: prevNodes, rotation: previousRotation });
802
+ if (stack.length > MAX_HISTORY) stack.shift();
803
+
804
+ // Clear redo stack on new action
805
+ redoStacks.delete(pageNum);
806
+ }
807
+
808
+ if (newState) {
809
+ annotationsStore.set(pageNum, newState);
810
+ annotationRotations.set(pageNum, pdfViewer.pagesRotation || 0);
811
+ } else {
812
+ annotationsStore.delete(pageNum);
813
+ annotationRotations.delete(pageNum);
814
+ }
815
+
816
+ updateUndoRedoButtons();
817
+ }
818
+
819
+ function updateUndoRedoButtons() {
820
+ const pageNum = pdfViewer ? pdfViewer.currentPageNumber : 0;
821
+ const undoBtn = document.getElementById('undoBtn');
822
+ const redoBtn = document.getElementById('redoBtn');
823
+ const undoStack = undoStacks.get(pageNum);
824
+ const redoStack = redoStacks.get(pageNum);
825
+ undoBtn.disabled = !undoStack || undoStack.length === 0;
826
+ redoBtn.disabled = !redoStack || redoStack.length === 0;
827
+ }
828
+
829
+ // Helper: clone SVG children into an array of nodes
830
+ function cloneSvgChildren(svg) {
831
+ return Array.from(svg.children).map(c => c.cloneNode(true));
832
+ }
833
+
834
+ // Helper: restore SVG from cloned nodes
835
+ function restoreSvgFromNodes(svg, nodes) {
836
+ svg.innerHTML = '';
837
+ nodes.forEach(n => svg.appendChild(n.cloneNode(true)));
838
+ }
839
+
840
+ function performUndo() {
841
+ const pageNum = pdfViewer.currentPageNumber;
842
+ const stack = undoStacks.get(pageNum);
843
+ if (!stack || stack.length === 0) return;
844
+
845
+ const pageView = pdfViewer.getPageView(pageNum - 1);
846
+ const svg = pageView?.div?.querySelector('.annotationLayer');
847
+ if (!svg) return;
848
+
849
+ // Save current state to redo stack
850
+ if (!redoStacks.has(pageNum)) redoStacks.set(pageNum, []);
851
+ const redoStack = redoStacks.get(pageNum);
852
+ redoStack.push({ nodes: cloneSvgChildren(svg), rotation: pdfViewer.pagesRotation || 0 });
853
+ if (redoStack.length > MAX_HISTORY) redoStack.shift();
854
+
855
+ // Restore previous state
856
+ const entry = stack.pop();
857
+ restoreSvgFromNodes(svg, entry.nodes);
858
+
859
+ // Update store
860
+ const restored = svg.innerHTML.trim();
861
+ if (restored) {
862
+ annotationsStore.set(pageNum, restored);
863
+ annotationRotations.set(pageNum, entry.rotation);
864
+ } else {
865
+ annotationsStore.delete(pageNum);
866
+ annotationRotations.delete(pageNum);
867
+ }
868
+
869
+ clearAnnotationSelection();
870
+ updateUndoRedoButtons();
871
+ }
872
+
873
+ function performRedo() {
874
+ const pageNum = pdfViewer.currentPageNumber;
875
+ const stack = redoStacks.get(pageNum);
876
+ if (!stack || stack.length === 0) return;
877
+
878
+ const pageView = pdfViewer.getPageView(pageNum - 1);
879
+ const svg = pageView?.div?.querySelector('.annotationLayer');
880
+ if (!svg) return;
881
+
882
+ // Save current state to undo stack
883
+ if (!undoStacks.has(pageNum)) undoStacks.set(pageNum, []);
884
+ const undoStack = undoStacks.get(pageNum);
885
+ undoStack.push({ nodes: cloneSvgChildren(svg), rotation: pdfViewer.pagesRotation || 0 });
886
+ if (undoStack.length > MAX_HISTORY) undoStack.shift();
887
+
888
+ // Restore redo state
889
+ const entry = stack.pop();
890
+ restoreSvgFromNodes(svg, entry.nodes);
891
+
892
+ // Update store
893
+ const restored = svg.innerHTML.trim();
894
+ if (restored) {
895
+ annotationsStore.set(pageNum, restored);
896
+ annotationRotations.set(pageNum, entry.rotation);
897
+ } else {
898
+ annotationsStore.delete(pageNum);
899
+ annotationRotations.delete(pageNum);
900
+ }
901
+
902
+ clearAnnotationSelection();
903
+ updateUndoRedoButtons();
904
+ }
905
+
906
+ function performClearAll() {
907
+ const pageNum = pdfViewer.currentPageNumber;
908
+ const pageView = pdfViewer.getPageView(pageNum - 1);
909
+ const svg = pageView?.div?.querySelector('.annotationLayer');
910
+ if (!svg || !svg.innerHTML.trim()) return;
911
+
912
+ // Save current state to undo stack (so it can be undone)
913
+ if (!undoStacks.has(pageNum)) undoStacks.set(pageNum, []);
914
+ const stack = undoStacks.get(pageNum);
915
+ stack.push({ nodes: cloneSvgChildren(svg), rotation: pdfViewer.pagesRotation || 0 });
916
+ if (stack.length > MAX_HISTORY) stack.shift();
917
+
918
+ // Clear redo stack
919
+ redoStacks.delete(pageNum);
920
+
921
+ // Clear all annotations
922
+ svg.innerHTML = '';
923
+ annotationsStore.delete(pageNum);
924
+ annotationRotations.delete(pageNum);
925
+
926
+ clearAnnotationSelection();
927
+ updateUndoRedoButtons();
928
+ }
929
+
930
+ function startDraw(e, pageNum) {
931
+ if (!annotationMode || !currentTool) return;
932
+
933
+ e.preventDefault(); // Prevent text selection
934
+
935
+ const svg = e.currentTarget;
936
+ if (!svg || !svg.dataset.viewboxWidth) return; // Defensive check
937
+
938
+ // Handle select tool separately
939
+ if (currentTool === 'select') {
940
+ if (handleSelectMouseDown(e, svg, pageNum)) {
941
+ return; // Select tool handled the event
942
+ }
943
+ }
944
+
945
+ isDrawing = true;
946
+ currentDrawingPage = pageNum;
947
+ currentSvg = svg; // Store reference
948
+
949
+ // Convert screen coords to viewBox coords (rotation-aware)
950
+ const coords = getEventCoords(e);
951
+ const vb = screenToViewBox(svg, coords.clientX, coords.clientY);
952
+ const x = vb.x;
953
+ const y = vb.y;
954
+ const scaleX = vb.scaleX;
955
+ const scaleY = vb.scaleY;
956
+
957
+ if (currentTool === 'eraser') {
958
+ isDrawing = true;
959
+ currentDrawingPage = pageNum;
960
+ currentSvg = svg;
961
+ eraseAt(svg, x, y, scaleX, coords.clientX, coords.clientY);
962
+ return;
963
+ }
964
+
965
+ // Text tool - create/edit/drag text
966
+ if (currentTool === 'text') {
967
+ // Check if clicked on existing text element
968
+ const elementsUnderClick = document.elementsFromPoint(e.clientX, e.clientY);
969
+ const existingText = elementsUnderClick.find(el => el.tagName === 'text' && el.closest('.annotationLayer'));
970
+
971
+ if (existingText) {
972
+ // Start dragging (double-click will edit via separate handler)
973
+ startTextDrag(e, existingText, svg, scaleX, pageNum);
974
+ } else {
975
+ // Create new text
976
+ showTextEditor(e.clientX, e.clientY, svg, x, y, scaleX, pageNum);
977
+ }
978
+ return;
979
+ }
980
+
981
+ // Shape tool - create shapes
982
+ if (currentTool === 'shape') {
983
+ isDrawing = true;
984
+ // Store start position for shape drawing
985
+ svg.dataset.shapeStartX = x;
986
+ svg.dataset.shapeStartY = y;
987
+ svg.dataset.shapeScaleX = scaleX;
988
+ svg.dataset.shapeScaleY = scaleY;
989
+
990
+ let shapeEl;
991
+ if (currentShape === 'rectangle') {
992
+ shapeEl = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
993
+ shapeEl.setAttribute('x', x);
994
+ shapeEl.setAttribute('y', y);
995
+ shapeEl.setAttribute('width', 0);
996
+ shapeEl.setAttribute('height', 0);
997
+ } else if (currentShape === 'circle') {
998
+ shapeEl = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse');
999
+ shapeEl.setAttribute('cx', x);
1000
+ shapeEl.setAttribute('cy', y);
1001
+ shapeEl.setAttribute('rx', 0);
1002
+ shapeEl.setAttribute('ry', 0);
1003
+ } else if (currentShape === 'line' || currentShape === 'arrow') {
1004
+ shapeEl = document.createElementNS('http://www.w3.org/2000/svg', 'line');
1005
+ shapeEl.setAttribute('x1', x);
1006
+ shapeEl.setAttribute('y1', y);
1007
+ shapeEl.setAttribute('x2', x);
1008
+ shapeEl.setAttribute('y2', y);
1009
+ }
1010
+
1011
+ shapeEl.setAttribute('stroke', currentColor);
1012
+ shapeEl.setAttribute('stroke-width', String(currentWidth * scaleX));
1013
+ shapeEl.setAttribute('fill', 'none');
1014
+ shapeEl.classList.add('current-shape');
1015
+ svg.appendChild(shapeEl);
1016
+ return;
1017
+ }
1018
+
1019
+ currentPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1020
+ currentPath.setAttribute('stroke', currentColor);
1021
+ currentPath.setAttribute('fill', 'none');
1022
+
1023
+ if (currentTool === 'highlight') {
1024
+ // Highlighter uses stroke size * 5 for thicker strokes
1025
+ currentPath.setAttribute('stroke-width', String(currentWidth * 5 * scaleX));
1026
+ currentPath.setAttribute('stroke-opacity', '0.35');
1027
+ } else {
1028
+ currentPath.setAttribute('stroke-width', String(currentWidth * scaleX));
1029
+ currentPath.setAttribute('stroke-opacity', '1');
1030
+ }
1031
+
1032
+ pathSegments = [`M${x.toFixed(2)},${y.toFixed(2)}`];
1033
+ currentPath.setAttribute('d', pathSegments[0]);
1034
+ svg.appendChild(currentPath);
1035
+ }
1036
+
1037
+ function draw(e) {
1038
+ if (!isDrawing || !currentSvg) return;
1039
+
1040
+ // Bug fix: Check if SVG is still in DOM (prevents stale reference)
1041
+ if (!currentSvg.isConnected) {
1042
+ isDrawing = false;
1043
+ currentPath = null;
1044
+ currentSvg = null;
1045
+ currentDrawingPage = null;
1046
+ return;
1047
+ }
1048
+
1049
+ e.preventDefault(); // Prevent text selection
1050
+
1051
+ const svg = currentSvg; // Use stored reference
1052
+ if (!svg || !svg.dataset.viewboxWidth) return;
1053
+
1054
+ // Convert screen coords to viewBox coords (rotation-aware)
1055
+ const coords = getEventCoords(e);
1056
+ const vb = screenToViewBox(svg, coords.clientX, coords.clientY);
1057
+ const x = vb.x;
1058
+ const y = vb.y;
1059
+ const scaleX = vb.scaleX;
1060
+
1061
+ if (currentTool === 'eraser') {
1062
+ eraseAt(svg, x, y, scaleX, coords.clientX, coords.clientY);
1063
+ return;
1064
+ }
1065
+
1066
+ // Shape tool - update shape size
1067
+ if (currentTool === 'shape') {
1068
+ const shapeEl = svg.querySelector('.current-shape');
1069
+ if (!shapeEl) return;
1070
+
1071
+ const startX = parseFloat(svg.dataset.shapeStartX);
1072
+ const startY = parseFloat(svg.dataset.shapeStartY);
1073
+
1074
+ if (currentShape === 'rectangle') {
1075
+ const width = Math.abs(x - startX);
1076
+ const height = Math.abs(y - startY);
1077
+ shapeEl.setAttribute('x', Math.min(x, startX));
1078
+ shapeEl.setAttribute('y', Math.min(y, startY));
1079
+ shapeEl.setAttribute('width', width);
1080
+ shapeEl.setAttribute('height', height);
1081
+ } else if (currentShape === 'circle') {
1082
+ const rx = Math.abs(x - startX) / 2;
1083
+ const ry = Math.abs(y - startY) / 2;
1084
+ shapeEl.setAttribute('cx', (startX + x) / 2);
1085
+ shapeEl.setAttribute('cy', (startY + y) / 2);
1086
+ shapeEl.setAttribute('rx', rx);
1087
+ shapeEl.setAttribute('ry', ry);
1088
+ } else if (currentShape === 'line' || currentShape === 'arrow' || currentShape === 'callout') {
1089
+ shapeEl.setAttribute('x2', x);
1090
+ shapeEl.setAttribute('y2', y);
1091
+ }
1092
+ return;
1093
+ }
1094
+
1095
+ if (currentPath) {
1096
+ pathSegments.push(`L${x.toFixed(2)},${y.toFixed(2)}`);
1097
+ if (!drawRAF) {
1098
+ drawRAF = requestAnimationFrame(() => {
1099
+ drawRAF = null;
1100
+ if (currentPath) currentPath.setAttribute('d', pathSegments.join(' '));
1101
+ });
1102
+ }
1103
+ }
1104
+ }
1105
+
1106
+ function stopDraw(pageNum) {
1107
+ // Handle arrow marker
1108
+ if (currentTool === 'shape' && currentShape === 'arrow' && currentSvg) {
1109
+ const shapeEl = currentSvg.querySelector('.current-shape');
1110
+ if (shapeEl && shapeEl.tagName === 'line') {
1111
+ // Create arrow head as a group
1112
+ const x1 = parseFloat(shapeEl.getAttribute('x1'));
1113
+ const y1 = parseFloat(shapeEl.getAttribute('y1'));
1114
+ const x2 = parseFloat(shapeEl.getAttribute('x2'));
1115
+ const y2 = parseFloat(shapeEl.getAttribute('y2'));
1116
+
1117
+ // Calculate arrow head
1118
+ const angle = Math.atan2(y2 - y1, x2 - x1);
1119
+ const headLength = 15 * parseFloat(currentSvg.dataset.shapeScaleX || 1);
1120
+
1121
+ const arrowHead = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1122
+ const p1x = x2 - headLength * Math.cos(angle - Math.PI / 6);
1123
+ const p1y = y2 - headLength * Math.sin(angle - Math.PI / 6);
1124
+ const p2x = x2 - headLength * Math.cos(angle + Math.PI / 6);
1125
+ const p2y = y2 - headLength * Math.sin(angle + Math.PI / 6);
1126
+
1127
+ arrowHead.setAttribute('d', `M${x2},${y2} L${p1x},${p1y} M${x2},${y2} L${p2x},${p2y}`);
1128
+ arrowHead.setAttribute('stroke', shapeEl.getAttribute('stroke'));
1129
+ arrowHead.setAttribute('stroke-width', shapeEl.getAttribute('stroke-width'));
1130
+ arrowHead.setAttribute('fill', 'none');
1131
+ currentSvg.appendChild(arrowHead);
1132
+ }
1133
+ }
1134
+
1135
+ // Handle callout - arrow with text at the start, pointing to end
1136
+ // UX: Click where you want text box, drag to point at something
1137
+ if (currentTool === 'shape' && currentShape === 'callout' && currentSvg) {
1138
+ const shapeEl = currentSvg.querySelector('.current-shape');
1139
+ if (shapeEl && shapeEl.tagName === 'line') {
1140
+ const x1 = parseFloat(shapeEl.getAttribute('x1')); // Start - where text box goes
1141
+ const y1 = parseFloat(shapeEl.getAttribute('y1'));
1142
+ const x2 = parseFloat(shapeEl.getAttribute('x2')); // End - where arrow points
1143
+ const y2 = parseFloat(shapeEl.getAttribute('y2'));
1144
+
1145
+ // Only create callout if line has been drawn (not just a click)
1146
+ if (Math.abs(x2 - x1) > 5 || Math.abs(y2 - y1) > 5) {
1147
+ const scaleX = parseFloat(currentSvg.dataset.shapeScaleX || 1);
1148
+
1149
+ // Arrow head points TO the end (x2,y2) - where user wants to point at
1150
+ const angle = Math.atan2(y2 - y1, x2 - x1);
1151
+ const headLength = 12 * scaleX;
1152
+
1153
+ const arrowHead = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1154
+ const p1x = x2 - headLength * Math.cos(angle - Math.PI / 6);
1155
+ const p1y = y2 - headLength * Math.sin(angle - Math.PI / 6);
1156
+ const p2x = x2 - headLength * Math.cos(angle + Math.PI / 6);
1157
+ const p2y = y2 - headLength * Math.sin(angle + Math.PI / 6);
1158
+
1159
+ arrowHead.setAttribute('d', `M${x2},${y2} L${p1x},${p1y} M${x2},${y2} L${p2x},${p2y}`);
1160
+ arrowHead.setAttribute('stroke', shapeEl.getAttribute('stroke'));
1161
+ arrowHead.setAttribute('stroke-width', shapeEl.getAttribute('stroke-width'));
1162
+ arrowHead.setAttribute('fill', 'none');
1163
+ arrowHead.classList.add('callout-arrow');
1164
+ currentSvg.appendChild(arrowHead);
1165
+
1166
+ // Store references for text editor
1167
+ const svg = currentSvg;
1168
+ const currentPageNum = currentDrawingPage;
1169
+ const arrowColor = shapeEl.getAttribute('stroke');
1170
+
1171
+ // Calculate screen position for text editor at START of arrow (x1,y1)
1172
+ // This is where the user clicked first - where they want the text
1173
+ const rect = svg.getBoundingClientRect();
1174
+ const viewBoxWidth = parseFloat(svg.dataset.viewboxWidth);
1175
+ const viewBoxHeight = parseFloat(svg.dataset.viewboxHeight);
1176
+ const screenX = rect.left + (x1 / viewBoxWidth) * rect.width;
1177
+ const screenY = rect.top + (y1 / viewBoxHeight) * rect.height;
1178
+
1179
+ // Remove the current-shape class before showing editor
1180
+ shapeEl.classList.remove('current-shape');
1181
+
1182
+ // Save first, then open text editor
1183
+ saveAnnotations(currentPageNum);
1184
+
1185
+ // Open text editor at the START of the arrow (where user clicked)
1186
+ setTimeout(() => {
1187
+ showTextEditor(screenX, screenY, svg, x1, y1, scaleX, currentPageNum, null, arrowColor);
1188
+ }, 50);
1189
+
1190
+ // Reset state
1191
+ isDrawing = false;
1192
+ currentPath = null;
1193
+ currentSvg = null;
1194
+ currentDrawingPage = null;
1195
+ return; // Exit early, text editor will handle the rest
1196
+ }
1197
+ }
1198
+ }
1199
+
1200
+ // Remove the current-shape class
1201
+ if (currentSvg) {
1202
+ const shapeEl = currentSvg.querySelector('.current-shape');
1203
+ if (shapeEl) shapeEl.classList.remove('current-shape');
1204
+ }
1205
+
1206
+ // Flush pending path segments
1207
+ if (currentPath && pathSegments.length > 0) {
1208
+ currentPath.setAttribute('d', pathSegments.join(' '));
1209
+ }
1210
+ pathSegments = [];
1211
+ if (drawRAF) { cancelAnimationFrame(drawRAF); drawRAF = null; }
1212
+
1213
+ if (isDrawing && currentDrawingPage) {
1214
+ saveAnnotations(currentDrawingPage);
1215
+ }
1216
+ isDrawing = false;
1217
+ currentPath = null;
1218
+ currentSvg = null;
1219
+ currentDrawingPage = null;
1220
+ }
1221
+
1222
+ // Text Drag-and-Drop
1223
+ let draggedText = null;
1224
+ let dragStartX = 0;
1225
+ let dragStartY = 0;
1226
+ let textOriginalX = 0;
1227
+ let textOriginalY = 0;
1228
+ let hasDragged = false;
1229
+
1230
+ function startTextDrag(e, textEl, svg, scaleX, pageNum) {
1231
+ e.preventDefault();
1232
+ e.stopPropagation();
1233
+
1234
+ draggedText = textEl;
1235
+ textEl.classList.add('dragging');
1236
+ hasDragged = false;
1237
+
1238
+ dragStartX = e.clientX;
1239
+ dragStartY = e.clientY;
1240
+ textOriginalX = parseFloat(textEl.getAttribute('x'));
1241
+ textOriginalY = parseFloat(textEl.getAttribute('y'));
1242
+
1243
+ function onMouseMove(ev) {
1244
+ const dxScreen = ev.clientX - dragStartX;
1245
+ const dyScreen = ev.clientY - dragStartY;
1246
+ // Convert screen delta to viewBox delta (rotation-aware)
1247
+ const vbDelta = screenDeltaToViewBox(svg, dxScreen, dyScreen, textEl);
1248
+
1249
+ if (Math.abs(vbDelta.dx) > 2 || Math.abs(vbDelta.dy) > 2) {
1250
+ hasDragged = true;
1251
+ }
1252
+
1253
+ textEl.setAttribute('x', (textOriginalX + vbDelta.dx).toFixed(2));
1254
+ textEl.setAttribute('y', (textOriginalY + vbDelta.dy).toFixed(2));
1255
+ }
1256
+
1257
+ function onMouseUp(ev) {
1258
+ document.removeEventListener('mousemove', onMouseMove);
1259
+ document.removeEventListener('mouseup', onMouseUp);
1260
+ textEl.classList.remove('dragging');
1261
+
1262
+ if (hasDragged) {
1263
+ // Moved - save position
1264
+ saveAnnotations(pageNum);
1265
+ } else {
1266
+ // Not moved - short click = edit
1267
+ const viewBoxWidth = parseFloat(svg.dataset.viewboxWidth);
1268
+ const viewBoxHeight = parseFloat(svg.dataset.viewboxHeight);
1269
+ const svgX = parseFloat(textEl.getAttribute('x'));
1270
+ const svgY = parseFloat(textEl.getAttribute('y'));
1271
+ // Note: showTextEditor needs scaleX for font scaling logic, which we still have from arguments
1272
+ showTextEditor(ev.clientX, ev.clientY, svg, svgX, svgY, scaleX, pageNum, textEl);
1273
+ }
1274
+
1275
+ draggedText = null;
1276
+ }
1277
+
1278
+ document.addEventListener('mousemove', onMouseMove);
1279
+ document.addEventListener('mouseup', onMouseUp);
1280
+ }
1281
+
1282
+ // Inline Text Editor
1283
+ let textFontSize = 20;
1284
+
1285
+ function showTextEditor(screenX, screenY, svg, svgX, svgY, scale, pageNum, existingTextEl = null, overrideColor = null) {
1286
+ // Remove existing editor if any
1287
+ const existingOverlay = document.querySelector('.textEditorOverlay');
1288
+ if (existingOverlay) existingOverlay.remove();
1289
+
1290
+ // Use override color (for callout) or current color
1291
+ let textColor = overrideColor || currentColor;
1292
+
1293
+ // If editing existing text, get its properties
1294
+ let editingText = null;
1295
+ if (existingTextEl && typeof existingTextEl === 'object' && existingTextEl.textContent !== undefined) {
1296
+ editingText = existingTextEl.textContent;
1297
+ textFontSize = parseFloat(existingTextEl.getAttribute('font-size')) / scale || 20;
1298
+ // Use existing text's color
1299
+ textColor = existingTextEl.getAttribute('fill') || textColor;
1300
+ }
1301
+
1302
+ // Create overlay
1303
+ const overlay = document.createElement('div');
1304
+ overlay.className = 'textEditorOverlay';
1305
+
1306
+ // Create editor box
1307
+ const box = document.createElement('div');
1308
+ box.className = 'textEditorBox';
1309
+ box.style.left = screenX + 'px';
1310
+ box.style.top = screenY + 'px';
1311
+
1312
+ // Input area
1313
+ const input = document.createElement('div');
1314
+ input.className = 'textEditorInput';
1315
+ input.contentEditable = true;
1316
+ input.style.color = textColor;
1317
+ input.style.fontSize = textFontSize + 'px';
1318
+ if (editingText) {
1319
+ input.textContent = editingText;
1320
+ }
1321
+
1322
+ // Toolbar
1323
+ const toolbar = document.createElement('div');
1324
+ toolbar.className = 'textEditorToolbar';
1325
+
1326
+ // Color palette
1327
+ const colorsDiv = document.createElement('div');
1328
+ colorsDiv.className = 'textEditorColors';
1329
+ const textEditorColors = ['#000000', '#e81224', '#0078d4', '#16c60c', '#fff100', '#886ce4', '#ff8c00', '#ffffff'];
1330
+ let activeColor = textColor;
1331
+
1332
+ textEditorColors.forEach(c => {
1333
+ const dot = document.createElement('div');
1334
+ dot.className = 'textEditorColorDot' + (c === activeColor ? ' active' : '');
1335
+ dot.style.background = c;
1336
+ if (c === '#ffffff') dot.style.border = '2px solid #ccc';
1337
+ dot.onclick = (e) => {
1338
+ e.stopPropagation();
1339
+ activeColor = c;
1340
+ input.style.color = c;
1341
+ colorsDiv.querySelectorAll('.textEditorColorDot').forEach(d => d.classList.remove('active'));
1342
+ dot.classList.add('active');
1343
+ };
1344
+ colorsDiv.appendChild(dot);
1345
+ });
1346
+
1347
+ // Font size group: A⁻ [size] A⁺
1348
+ const sizeGroup = document.createElement('div');
1349
+ sizeGroup.className = 'textEditorSizeGroup';
1350
+
1351
+ const sizeLabel = document.createElement('span');
1352
+ sizeLabel.className = 'textEditorSizeLabel';
1353
+ sizeLabel.textContent = textFontSize;
1354
+
1355
+ const decreaseBtn = document.createElement('button');
1356
+ decreaseBtn.className = 'textEditorBtn';
1357
+ decreaseBtn.innerHTML = 'A<sup>-</sup>';
1358
+ decreaseBtn.onclick = (e) => {
1359
+ e.stopPropagation();
1360
+ if (textFontSize > 10) {
1361
+ textFontSize -= 2;
1362
+ input.style.fontSize = textFontSize + 'px';
1363
+ sizeLabel.textContent = textFontSize;
1364
+ }
1365
+ };
1366
+
1367
+ const increaseBtn = document.createElement('button');
1368
+ increaseBtn.className = 'textEditorBtn';
1369
+ increaseBtn.innerHTML = 'A<sup>+</sup>';
1370
+ increaseBtn.onclick = (e) => {
1371
+ e.stopPropagation();
1372
+ if (textFontSize < 60) {
1373
+ textFontSize += 2;
1374
+ input.style.fontSize = textFontSize + 'px';
1375
+ sizeLabel.textContent = textFontSize;
1376
+ }
1377
+ };
1378
+
1379
+ sizeGroup.appendChild(decreaseBtn);
1380
+ sizeGroup.appendChild(sizeLabel);
1381
+ sizeGroup.appendChild(increaseBtn);
1382
+
1383
+ // Delete button
1384
+ const deleteBtn = document.createElement('button');
1385
+ deleteBtn.className = 'textEditorBtn delete';
1386
+ deleteBtn.innerHTML = '🗑️';
1387
+ deleteBtn.onclick = (e) => {
1388
+ e.stopPropagation();
1389
+ if (existingTextEl) {
1390
+ existingTextEl.remove();
1391
+ saveAnnotations(pageNum);
1392
+ }
1393
+ overlay.remove();
1394
+ };
1395
+
1396
+ toolbar.appendChild(colorsDiv);
1397
+ toolbar.appendChild(sizeGroup);
1398
+ toolbar.appendChild(deleteBtn);
1399
+
1400
+ box.appendChild(input);
1401
+ box.appendChild(toolbar);
1402
+ overlay.appendChild(box);
1403
+ document.body.appendChild(overlay);
1404
+
1405
+ // Focus input and select all if editing
1406
+ setTimeout(() => {
1407
+ input.focus();
1408
+ if (editingText) {
1409
+ const range = document.createRange();
1410
+ range.selectNodeContents(input);
1411
+ const sel = window.getSelection();
1412
+ sel.removeAllRanges();
1413
+ sel.addRange(range);
1414
+ }
1415
+ }, 50);
1416
+
1417
+ // Confirm on click outside or Enter
1418
+ function confirmText() {
1419
+ const text = input.textContent.trim();
1420
+ if (text) {
1421
+ if (existingTextEl) {
1422
+ // Update existing text element
1423
+ existingTextEl.textContent = text;
1424
+ existingTextEl.setAttribute('fill', activeColor);
1425
+ existingTextEl.setAttribute('font-size', String(textFontSize * scale));
1426
+ } else {
1427
+ // Create new text element
1428
+ const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
1429
+ textEl.setAttribute('x', svgX.toFixed(2));
1430
+ textEl.setAttribute('y', svgY.toFixed(2));
1431
+ textEl.setAttribute('fill', activeColor);
1432
+ textEl.setAttribute('font-size', String(textFontSize * scale));
1433
+ textEl.setAttribute('font-family', 'Segoe UI, Arial, sans-serif');
1434
+ textEl.textContent = text;
1435
+ svg.appendChild(textEl);
1436
+ }
1437
+ saveAnnotations(pageNum);
1438
+ } else if (existingTextEl) {
1439
+ // Empty text = delete existing
1440
+ existingTextEl.remove();
1441
+ saveAnnotations(pageNum);
1442
+ }
1443
+ overlay.remove();
1444
+ }
1445
+
1446
+ overlay.addEventListener('click', (e) => {
1447
+ if (e.target === overlay) confirmText();
1448
+ });
1449
+
1450
+ input.addEventListener('keydown', (e) => {
1451
+ if (e.key === 'Enter' && !e.shiftKey) {
1452
+ e.preventDefault();
1453
+ confirmText();
1454
+ }
1455
+ if (e.key === 'Escape') {
1456
+ overlay.remove();
1457
+ }
1458
+ });
1459
+ }
1460
+
1461
+ function eraseAt(svg, x, y, scale = 1, clientX, clientY) {
1462
+ // Use browser-optimized hit-test for SVG annotation elements
1463
+ const annotationTags = new Set(['path', 'rect', 'ellipse', 'line', 'text']);
1464
+ const elements = document.elementsFromPoint(clientX, clientY);
1465
+ elements.forEach(el => {
1466
+ if (el.closest('.annotationLayer') === svg && annotationTags.has(el.tagName)) {
1467
+ el.remove();
1468
+ }
1469
+ });
1470
+
1471
+ // Also erase text highlights (in separate container)
1472
+ const pageDiv = svg.closest('.page');
1473
+ if (pageDiv) {
1474
+ const highlightContainer = pageDiv.querySelector('.textHighlightContainer');
1475
+ if (highlightContainer) {
1476
+ const vbW = parseFloat(svg.dataset.viewboxWidth);
1477
+ const vbH = parseFloat(svg.dataset.viewboxHeight);
1478
+ const screenXPercent = (x / vbW) * 100;
1479
+ const screenYPercent = (y / vbH) * 100;
1480
+
1481
+ highlightContainer.querySelectorAll('.textHighlight').forEach(el => {
1482
+ const left = parseFloat(el.style.left);
1483
+ const top = parseFloat(el.style.top);
1484
+ const width = parseFloat(el.style.width);
1485
+ const height = parseFloat(el.style.height);
1486
+
1487
+ if (screenXPercent >= left - 2 && screenXPercent <= left + width + 2 &&
1488
+ screenYPercent >= top - 2 && screenYPercent <= top + height + 2) {
1489
+ el.remove();
1490
+ const pageNum = parseInt(pageDiv.dataset.pageNumber);
1491
+ saveTextHighlights(pageNum, pageDiv);
1492
+ }
1493
+ });
1494
+ }
1495
+ }
1496
+ }
1497
+
1498
+ // ==========================================
1499
+ // TEXT SELECTION HIGHLIGHTING (Adobe/Edge style)
1500
+ // ==========================================
1501
+ let highlightPopup = null;
1502
+
1503
+ function removeHighlightPopup() {
1504
+ if (highlightPopup) {
1505
+ highlightPopup.remove();
1506
+ highlightPopup = null;
1507
+ }
1508
+ }
1509
+
1510
+ function getSelectionRects() {
1511
+ const selection = window.getSelection();
1512
+ if (!selection || selection.isCollapsed || !selection.rangeCount) return null;
1513
+
1514
+ const range = selection.getRangeAt(0);
1515
+ const rects = range.getClientRects();
1516
+ if (rects.length === 0) return null;
1517
+
1518
+ // Find which page the selection is in
1519
+ const startNode = range.startContainer.parentElement;
1520
+ const textLayer = startNode?.closest('.textLayer');
1521
+ if (!textLayer) return null;
1522
+
1523
+ const pageDiv = textLayer.closest('.page');
1524
+ if (!pageDiv) return null;
1525
+
1526
+ const pageNum = parseInt(pageDiv.dataset.pageNumber);
1527
+ const pageRect = pageDiv.getBoundingClientRect();
1528
+
1529
+ // Convert rects to page-relative coordinates
1530
+ const relativeRects = [];
1531
+ for (let i = 0; i < rects.length; i++) {
1532
+ const rect = rects[i];
1533
+ relativeRects.push({
1534
+ x: rect.left - pageRect.left,
1535
+ y: rect.top - pageRect.top,
1536
+ width: rect.width,
1537
+ height: rect.height
1538
+ });
1539
+ }
1540
+
1541
+ return { pageNum, pageDiv, relativeRects, lastRect: rects[rects.length - 1] };
1542
+ }
1543
+
1544
+ function createTextHighlights(pageDiv, rects, color) {
1545
+ // Find or create highlight container
1546
+ let highlightContainer = pageDiv.querySelector('.textHighlightContainer');
1547
+ if (!highlightContainer) {
1548
+ highlightContainer = document.createElement('div');
1549
+ highlightContainer.className = 'textHighlightContainer';
1550
+ highlightContainer.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:5;';
1551
+ pageDiv.insertBefore(highlightContainer, pageDiv.firstChild);
1552
+ }
1553
+
1554
+ // Get page dimensions for percentage calculation
1555
+ const pageRect = pageDiv.getBoundingClientRect();
1556
+ const pageWidth = pageRect.width;
1557
+ const pageHeight = pageRect.height;
1558
+
1559
+ // Add highlight rectangles with percentage positioning
1560
+ rects.forEach(rect => {
1561
+ const div = document.createElement('div');
1562
+ div.className = 'textHighlight';
1563
+
1564
+ // Convert to percentages for zoom-independent positioning
1565
+ const leftPercent = (rect.x / pageWidth) * 100;
1566
+ const topPercent = (rect.y / pageHeight) * 100;
1567
+ const widthPercent = (rect.width / pageWidth) * 100;
1568
+ const heightPercent = (rect.height / pageHeight) * 100;
1569
+
1570
+ div.style.cssText = `
1571
+ left: ${leftPercent}%;
1572
+ top: ${topPercent}%;
1573
+ width: ${widthPercent}%;
1574
+ height: ${heightPercent}%;
1575
+ background: ${color};
1576
+ opacity: 0.35;
1577
+ `;
1578
+ highlightContainer.appendChild(div);
1579
+ });
1580
+
1581
+ // Save to annotations store
1582
+ const pageNum = parseInt(pageDiv.dataset.pageNumber);
1583
+ saveTextHighlights(pageNum, pageDiv);
1584
+ }
1585
+
1586
+ function saveTextHighlights(pageNum, pageDiv) {
1587
+ const container = pageDiv.querySelector('.textHighlightContainer');
1588
+ if (container) {
1589
+ const key = `textHighlight_${pageNum}`;
1590
+ localStorage.setItem(key, container.innerHTML);
1591
+ }
1592
+ }
1593
+
1594
+ function loadTextHighlights(pageNum, pageDiv) {
1595
+ const key = `textHighlight_${pageNum}`;
1596
+ const saved = localStorage.getItem(key);
1597
+ if (saved) {
1598
+ let container = pageDiv.querySelector('.textHighlightContainer');
1599
+ if (!container) {
1600
+ container = document.createElement('div');
1601
+ container.className = 'textHighlightContainer';
1602
+ container.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:5;';
1603
+ pageDiv.insertBefore(container, pageDiv.firstChild);
1604
+ }
1605
+ container.innerHTML = saved;
1606
+ }
1607
+ }
1608
+
1609
+ function showHighlightPopup(x, y, pageDiv, rects) {
1610
+ removeHighlightPopup();
1611
+
1612
+ highlightPopup = document.createElement('div');
1613
+ highlightPopup.className = 'highlightPopup';
1614
+ highlightPopup.style.left = x + 'px';
1615
+ highlightPopup.style.top = (y + 10) + 'px';
1616
+
1617
+ const colors = ['#fff100', '#16c60c', '#00b7c3', '#0078d4', '#886ce4', '#e81224'];
1618
+ colors.forEach(color => {
1619
+ const btn = document.createElement('button');
1620
+ btn.style.background = color;
1621
+ btn.title = 'Vurgula';
1622
+ btn.onclick = (e) => {
1623
+ e.stopPropagation();
1624
+ createTextHighlights(pageDiv, rects, color);
1625
+ window.getSelection().removeAllRanges();
1626
+ removeHighlightPopup();
1627
+ };
1628
+ highlightPopup.appendChild(btn);
1629
+ });
1630
+
1631
+ document.body.appendChild(highlightPopup);
1632
+ }
1633
+
1634
+ // Listen for text selection
1635
+ document.addEventListener('mouseup', (e) => {
1636
+ // Small delay to let selection finalize
1637
+ setTimeout(() => {
1638
+ const selData = getSelectionRects();
1639
+ if (selData && selData.relativeRects.length > 0) {
1640
+ const lastRect = selData.lastRect;
1641
+ showHighlightPopup(lastRect.right, lastRect.bottom, selData.pageDiv, selData.relativeRects);
1642
+ } else {
1643
+ removeHighlightPopup();
1644
+ }
1645
+ }, 10);
1646
+ });
1647
+
1648
+ // Remove popup on click elsewhere
1649
+ document.addEventListener('mousedown', (e) => {
1650
+ if (highlightPopup && !highlightPopup.contains(e.target)) {
1651
+ removeHighlightPopup();
1652
+ }
1653
+ });
1654
+
1655
+ // Load text highlights when pages render
1656
+ eventBus.on('pagerendered', (evt) => {
1657
+ const pageDiv = pdfViewer.getPageView(evt.pageNumber - 1)?.div;
1658
+ if (pageDiv) {
1659
+ loadTextHighlights(evt.pageNumber, pageDiv);
1660
+ }
1661
+ });
1662
+
1663
+ // ==========================================
1664
+ // SELECT/MOVE TOOL (Fixed + Touch Support)
1665
+ // ==========================================
1666
+ let selectedAnnotation = null;
1667
+ let selectedSvg = null;
1668
+ let selectedPageNum = null;
1669
+ let copiedAnnotation = null;
1670
+ let copiedPageNum = null;
1671
+ let isDraggingAnnotation = false;
1672
+ let annotationDragStartX = 0;
1673
+ let annotationDragStartY = 0;
1674
+
1675
+ // Marquee selection state
1676
+ let marqueeActive = false;
1677
+ let marqueeStartX = 0, marqueeStartY = 0;
1678
+ let marqueeRect = null;
1679
+ let marqueeSvg = null;
1680
+ let marqueePageNum = null;
1681
+ let multiSelectedAnnotations = [];
1682
+
1683
+ // Create selection toolbar for touch devices
1684
+ const selectionToolbar = document.createElement('div');
1685
+ selectionToolbar.className = 'selection-toolbar';
1686
+ selectionToolbar.innerHTML = `
1687
+ <button data-action="copy" title="Kopyala (Ctrl+C)">
1688
+ <svg viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
1689
+ <span>Kopyala</span>
1690
+ </button>
1691
+ <button data-action="duplicate" title="Çoğalt">
1692
+ <svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"/></svg>
1693
+ <span>Çoğalt</span>
1694
+ </button>
1695
+ <button data-action="delete" class="delete" title="Sil (Del)">
1696
+ <svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
1697
+ <span>Sil</span>
1698
+ </button>
1699
+ `;
1700
+ document.body.appendChild(selectionToolbar);
1701
+
1702
+ // Selection toolbar event handlers
1703
+ selectionToolbar.addEventListener('click', (e) => {
1704
+ const btn = e.target.closest('button');
1705
+ if (!btn) return;
1706
+
1707
+ const action = btn.dataset.action;
1708
+ if (action === 'copy') {
1709
+ copySelectedAnnotation();
1710
+ showToast('Kopyalandı!');
1711
+ } else if (action === 'duplicate') {
1712
+ copySelectedAnnotation();
1713
+ pasteAnnotation();
1714
+ showToast('Çoğaltıldı!');
1715
+ } else if (action === 'delete') {
1716
+ deleteSelectedAnnotation();
1717
+ showToast('Silindi!');
1718
+ }
1719
+ });
1720
+
1721
+ function showToast(message) {
1722
+ const existingToast = document.querySelector('.toast-notification');
1723
+ if (existingToast) existingToast.remove();
1724
+
1725
+ const toast = document.createElement('div');
1726
+ toast.className = 'toast-notification';
1727
+ toast.textContent = message;
1728
+ document.body.appendChild(toast);
1729
+ setTimeout(() => toast.remove(), 2000);
1730
+ }
1731
+
1732
+ function updateSelectionToolbar() {
1733
+ if (selectedAnnotation && currentTool === 'select') {
1734
+ selectionToolbar.classList.add('visible');
1735
+ } else {
1736
+ selectionToolbar.classList.remove('visible');
1737
+ }
1738
+ }
1739
+
1740
+ function clearMultiSelection() {
1741
+ if (multiDragHandler) {
1742
+ multiSelectedAnnotations.forEach(el => {
1743
+ el.removeEventListener('mousedown', multiDragHandler);
1744
+ el.removeEventListener('touchstart', multiDragHandler);
1745
+ });
1746
+ multiDragHandler = null;
1747
+ }
1748
+ multiSelectedAnnotations.forEach(el => {
1749
+ el.classList.remove('annotation-multi-selected');
1750
+ el.style.cursor = '';
1751
+ });
1752
+ multiSelectedAnnotations = [];
1753
+ }
1754
+
1755
+ function clearAnnotationSelection() {
1756
+ if (selectedAnnotation) {
1757
+ selectedAnnotation.classList.remove('annotation-selected', 'annotation-dragging', 'just-selected');
1758
+ }
1759
+ selectedAnnotation = null;
1760
+ selectedSvg = null;
1761
+ selectedPageNum = null;
1762
+ isDraggingAnnotation = false;
1763
+ clearMultiSelection();
1764
+ updateSelectionToolbar();
1765
+ }
1766
+
1767
+ function selectAnnotation(element, svg, pageNum) {
1768
+ clearAnnotationSelection();
1769
+ selectedAnnotation = element;
1770
+ selectedSvg = svg;
1771
+ selectedPageNum = pageNum;
1772
+ element.classList.add('annotation-selected', 'just-selected');
1773
+
1774
+ // Remove pulse animation after it completes
1775
+ setTimeout(() => {
1776
+ element.classList.remove('just-selected');
1777
+ }, 600);
1778
+
1779
+ updateSelectionToolbar();
1780
+ }
1781
+
1782
+ function deleteSelectedAnnotation() {
1783
+ if (multiSelectedAnnotations.length > 0 && marqueeSvg) {
1784
+ // Delete all multi-selected annotations
1785
+ const pageNum = marqueePageNum;
1786
+ multiSelectedAnnotations.forEach(el => el.remove());
1787
+ clearMultiSelection();
1788
+ if (marqueeSvg && marqueeSvg.isConnected) saveAnnotations(pageNum);
1789
+ marqueeSvg = null;
1790
+ marqueePageNum = null;
1791
+ } else if (selectedAnnotation && selectedSvg) {
1792
+ selectedAnnotation.remove();
1793
+ saveAnnotations(selectedPageNum);
1794
+ clearAnnotationSelection();
1795
+ }
1796
+ }
1797
+
1798
+ function copySelectedAnnotation() {
1799
+ if (selectedAnnotation) {
1800
+ copiedAnnotation = selectedAnnotation.cloneNode(true);
1801
+ copiedAnnotation.classList.remove('annotation-selected', 'annotation-dragging', 'just-selected');
1802
+ copiedPageNum = selectedPageNum;
1803
+ }
1804
+ }
1805
+
1806
+ function pasteAnnotation() {
1807
+ if (!copiedAnnotation || !pdfViewer) return;
1808
+
1809
+ // Paste to current page
1810
+ const currentPage = pdfViewer.currentPageNumber;
1811
+ const pageView = pdfViewer.getPageView(currentPage - 1);
1812
+ const svg = pageView?.div?.querySelector('.annotationLayer');
1813
+
1814
+ if (svg) {
1815
+ const cloned = copiedAnnotation.cloneNode(true);
1816
+ const offset = 30; // Offset amount for pasted elements
1817
+
1818
+ // Offset pasted element slightly
1819
+ if (cloned.tagName === 'path') {
1820
+ // For paths, add/update transform translate
1821
+ const currentTransform = cloned.getAttribute('transform') || '';
1822
+ const match = currentTransform.match(/translate\(([^,]+),([^)]+)\)/);
1823
+ let tx = offset, ty = offset;
1824
+ if (match) {
1825
+ tx = parseFloat(match[1]) + offset;
1826
+ ty = parseFloat(match[2]) + offset;
1827
+ }
1828
+ cloned.setAttribute('transform', `translate(${tx}, ${ty})`);
1829
+ } else if (cloned.tagName === 'rect') {
1830
+ cloned.setAttribute('x', parseFloat(cloned.getAttribute('x')) + offset);
1831
+ cloned.setAttribute('y', parseFloat(cloned.getAttribute('y')) + offset);
1832
+ } else if (cloned.tagName === 'ellipse') {
1833
+ cloned.setAttribute('cx', parseFloat(cloned.getAttribute('cx')) + offset);
1834
+ cloned.setAttribute('cy', parseFloat(cloned.getAttribute('cy')) + offset);
1835
+ } else if (cloned.tagName === 'line') {
1836
+ cloned.setAttribute('x1', parseFloat(cloned.getAttribute('x1')) + offset);
1837
+ cloned.setAttribute('y1', parseFloat(cloned.getAttribute('y1')) + offset);
1838
+ cloned.setAttribute('x2', parseFloat(cloned.getAttribute('x2')) + offset);
1839
+ cloned.setAttribute('y2', parseFloat(cloned.getAttribute('y2')) + offset);
1840
+ } else if (cloned.tagName === 'text') {
1841
+ cloned.setAttribute('x', parseFloat(cloned.getAttribute('x')) + offset);
1842
+ cloned.setAttribute('y', parseFloat(cloned.getAttribute('y')) + offset);
1843
+ }
1844
+
1845
+ svg.appendChild(cloned);
1846
+ saveAnnotations(currentPage);
1847
+ selectAnnotation(cloned, svg, currentPage);
1848
+ }
1849
+ }
1850
+
1851
+ // Get coordinates from mouse or touch event
1852
+ function getEventCoords(e) {
1853
+ if (e.touches && e.touches.length > 0) {
1854
+ return { clientX: e.touches[0].clientX, clientY: e.touches[0].clientY };
1855
+ }
1856
+ if (e.changedTouches && e.changedTouches.length > 0) {
1857
+ return { clientX: e.changedTouches[0].clientX, clientY: e.changedTouches[0].clientY };
1858
+ }
1859
+ return { clientX: e.clientX, clientY: e.clientY };
1860
+ }
1861
+
1862
+ // Convert screen coordinates to viewBox coordinates, accounting for CSS rotation
1863
+ function screenToViewBox(svg, clientX, clientY) {
1864
+ const rect = svg.getBoundingClientRect();
1865
+ const vbW = parseFloat(svg.dataset.viewboxWidth);
1866
+ const vbH = parseFloat(svg.dataset.viewboxHeight);
1867
+
1868
+ // Offset from center in screen pixels
1869
+ const cx = rect.left + rect.width / 2;
1870
+ const cy = rect.top + rect.height / 2;
1871
+ const udx = clientX - cx;
1872
+ const udy = clientY - cy;
1873
+
1874
+ // Element dimensions (no CSS rotation — PDF.js handles rotation natively)
1875
+ let elemW, elemH;
1876
+ {
1877
+ elemW = rect.width;
1878
+ elemH = rect.height;
1879
+ }
1880
+
1881
+ // Map to viewBox: center-relative to 0,0-relative
1882
+ const x = (udx + elemW / 2) * (vbW / elemW);
1883
+ const y = (udy + elemH / 2) * (vbH / elemH);
1884
+
1885
+ const scaleX = vbW / elemW;
1886
+ const scaleY = vbH / elemH;
1887
+
1888
+ return { x, y, scaleX, scaleY };
1889
+ }
1890
+
1891
+ // Convert screen delta (dx,dy pixels) to viewBox delta
1892
+ // If element is inside a rotated <g>, counter-rotate the delta
1893
+ function screenDeltaToViewBox(svg, dxScreen, dyScreen, element) {
1894
+ const rect = svg.getBoundingClientRect();
1895
+ const vbW = parseFloat(svg.dataset.viewboxWidth);
1896
+ const vbH = parseFloat(svg.dataset.viewboxHeight);
1897
+
1898
+ let dx = dxScreen * (vbW / rect.width);
1899
+ let dy = dyScreen * (vbH / rect.height);
1900
+
1901
+ // Check if element is inside a rotated <g> wrapper
1902
+ if (element) {
1903
+ const parentG = element.parentElement;
1904
+ if (parentG && parentG.tagName === 'g' && parentG.getAttribute('transform')) {
1905
+ const t = parentG.getAttribute('transform');
1906
+ const rotMatch = t.match(/rotate\((\d+)\)/);
1907
+ if (rotMatch) {
1908
+ const rot = parseInt(rotMatch[1]);
1909
+ // Counter-rotate the delta to match <g>'s local coordinate system
1910
+ if (rot === 90) { const tmp = dx; dx = dy; dy = -tmp; }
1911
+ else if (rot === 180) { dx = -dx; dy = -dy; }
1912
+ else if (rot === 270) { const tmp = dx; dx = -dy; dy = tmp; }
1913
+ }
1914
+ }
1915
+ }
1916
+
1917
+ return { dx, dy };
1918
+ }
1919
+
1920
+ // Handle select tool events (both mouse and touch)
1921
+ function handleSelectPointerDown(e, svg, pageNum) {
1922
+ if (currentTool !== 'select') return false;
1923
+
1924
+ const coords = getEventCoords(e);
1925
+ const target = e.target;
1926
+
1927
+ if (target === svg || target.tagName === 'svg') {
1928
+ // Clicked on empty area — clear selections and start marquee
1929
+ clearAnnotationSelection();
1930
+
1931
+ const pt = screenToViewBox(svg, coords.clientX, coords.clientY);
1932
+
1933
+ marqueeActive = true;
1934
+ marqueeStartX = pt.x;
1935
+ marqueeStartY = pt.y;
1936
+ marqueeSvg = svg;
1937
+ marqueePageNum = pageNum;
1938
+
1939
+ // Create marquee rectangle
1940
+ marqueeRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1941
+ marqueeRect.setAttribute('class', 'marquee-rect');
1942
+ marqueeRect.setAttribute('x', pt.x);
1943
+ marqueeRect.setAttribute('y', pt.y);
1944
+ marqueeRect.setAttribute('width', 0);
1945
+ marqueeRect.setAttribute('height', 0);
1946
+ svg.appendChild(marqueeRect);
1947
+
1948
+ let marqueeRAF = null;
1949
+ function onMarqueeMove(ev) {
1950
+ if (!marqueeActive || !marqueeRect) return;
1951
+ ev.preventDefault();
1952
+
1953
+ const moveCoords = getEventCoords(ev);
1954
+ const mpt = screenToViewBox(marqueeSvg, moveCoords.clientX, moveCoords.clientY);
1955
+
1956
+ if (!marqueeRAF) {
1957
+ marqueeRAF = requestAnimationFrame(() => {
1958
+ marqueeRAF = null;
1959
+ if (!marqueeRect) return;
1960
+ const x = Math.min(marqueeStartX, mpt.x);
1961
+ const y = Math.min(marqueeStartY, mpt.y);
1962
+ const w = Math.abs(mpt.x - marqueeStartX);
1963
+ const h = Math.abs(mpt.y - marqueeStartY);
1964
+
1965
+ marqueeRect.setAttribute('x', x);
1966
+ marqueeRect.setAttribute('y', y);
1967
+ marqueeRect.setAttribute('width', w);
1968
+ marqueeRect.setAttribute('height', h);
1969
+ });
1970
+ }
1971
+ }
1972
+
1973
+ function onMarqueeEnd(ev) {
1974
+ document.removeEventListener('mousemove', onMarqueeMove);
1975
+ document.removeEventListener('mouseup', onMarqueeEnd);
1976
+ document.removeEventListener('touchmove', onMarqueeMove);
1977
+ document.removeEventListener('touchend', onMarqueeEnd);
1978
+ document.removeEventListener('touchcancel', onMarqueeEnd);
1979
+
1980
+ if (!marqueeRect || !marqueeSvg) { marqueeActive = false; return; }
1981
+
1982
+ // Marquee bounds
1983
+ const mx = parseFloat(marqueeRect.getAttribute('x'));
1984
+ const my = parseFloat(marqueeRect.getAttribute('y'));
1985
+ const mw = parseFloat(marqueeRect.getAttribute('width'));
1986
+ const mh = parseFloat(marqueeRect.getAttribute('height'));
1987
+
1988
+ // Remove marquee rectangle
1989
+ marqueeRect.remove();
1990
+ marqueeRect = null;
1991
+ marqueeActive = false;
1992
+
1993
+ // Ignore tiny marquees (accidental clicks)
1994
+ if (mw < 5 && mh < 5) return;
1995
+
1996
+ // Find elements intersecting the marquee
1997
+ const elements = marqueeSvg.querySelectorAll('path, rect, ellipse, line, text');
1998
+ multiSelectedAnnotations = [];
1999
+
2000
+ elements.forEach(el => {
2001
+ // Skip the marquee rect class itself (already removed, but safety)
2002
+ if (el.classList.contains('marquee-rect')) return;
2003
+
2004
+ const bbox = el.getBBox();
2005
+ let ex = bbox.x, ey = bbox.y;
2006
+ const transform = el.getAttribute('transform');
2007
+ if (transform) {
2008
+ const match = transform.match(/translate\(([^,]+),\s*([^)]+)\)/);
2009
+ if (match) { ex += parseFloat(match[1]); ey += parseFloat(match[2]); }
2010
+ }
2011
+
2012
+ // AABB intersection test
2013
+ if (ex + bbox.width > mx && ex < mx + mw &&
2014
+ ey + bbox.height > my && ey < my + mh) {
2015
+ el.classList.add('annotation-multi-selected');
2016
+ multiSelectedAnnotations.push(el);
2017
+ }
2018
+ });
2019
+
2020
+ // Enable multi-drag if we selected anything
2021
+ if (multiSelectedAnnotations.length > 0) {
2022
+ setupMultiDrag(marqueeSvg, marqueePageNum);
2023
+ }
2024
+ }
2025
+
2026
+ document.addEventListener('mousemove', onMarqueeMove, { passive: false });
2027
+ document.addEventListener('mouseup', onMarqueeEnd);
2028
+ document.addEventListener('touchmove', onMarqueeMove, { passive: false });
2029
+ document.addEventListener('touchend', onMarqueeEnd);
2030
+ document.addEventListener('touchcancel', onMarqueeEnd);
2031
+
2032
+ return true;
2033
+ }
2034
+
2035
+ // Check if clicked on an annotation element
2036
+ if (target.closest('.annotationLayer') && target !== svg) {
2037
+ e.preventDefault();
2038
+ e.stopPropagation();
2039
+
2040
+ selectAnnotation(target, svg, pageNum);
2041
+
2042
+ // Start drag
2043
+ isDraggingAnnotation = true;
2044
+ annotationDragStartX = coords.clientX;
2045
+ annotationDragStartY = coords.clientY;
2046
+
2047
+ target.classList.add('annotation-dragging');
2048
+
2049
+ function onMove(ev) {
2050
+ if (!isDraggingAnnotation) return;
2051
+ ev.preventDefault();
2052
+
2053
+ const moveCoords = getEventCoords(ev);
2054
+ const dxScreen = moveCoords.clientX - annotationDragStartX;
2055
+ const dyScreen = moveCoords.clientY - annotationDragStartY;
2056
+
2057
+ // Convert screen delta to viewBox delta (rotation-aware)
2058
+ const vbDelta = screenDeltaToViewBox(svg, dxScreen, dyScreen, target);
2059
+
2060
+ // Move the element
2061
+ moveAnnotation(target, vbDelta.dx, vbDelta.dy);
2062
+
2063
+ // Update start position for next move
2064
+ annotationDragStartX = moveCoords.clientX;
2065
+ annotationDragStartY = moveCoords.clientY;
2066
+ }
2067
+
2068
+ function onEnd(ev) {
2069
+ document.removeEventListener('mousemove', onMove);
2070
+ document.removeEventListener('mouseup', onEnd);
2071
+ document.removeEventListener('touchmove', onMove);
2072
+ document.removeEventListener('touchend', onEnd);
2073
+ document.removeEventListener('touchcancel', onEnd);
2074
+
2075
+ target.classList.remove('annotation-dragging');
2076
+ isDraggingAnnotation = false;
2077
+
2078
+ // Bug fix: Clamp annotation within page bounds to prevent cross-page loss
2079
+ const vbW = parseFloat(svg.dataset.viewboxWidth);
2080
+ const vbH = parseFloat(svg.dataset.viewboxHeight);
2081
+ clampAnnotationToPage(target, vbW, vbH);
2082
+
2083
+ // Bug fix: Check if SVG is still in DOM before saving
2084
+ if (svg.isConnected) {
2085
+ saveAnnotations(pageNum);
2086
+ }
2087
+ }
2088
+
2089
+ document.addEventListener('mousemove', onMove, { passive: false });
2090
+ document.addEventListener('mouseup', onEnd);
2091
+ document.addEventListener('touchmove', onMove, { passive: false });
2092
+ document.addEventListener('touchend', onEnd);
2093
+ document.addEventListener('touchcancel', onEnd);
2094
+
2095
+ return true;
2096
+ }
2097
+
2098
+ return false;
2099
+ }
2100
+
2101
+ // Multi-drag handler reference for cleanup
2102
+ let multiDragHandler = null;
2103
+
2104
+ // Setup multi-drag for marquee-selected annotations
2105
+ function setupMultiDrag(svg, pageNum) {
2106
+ function startMultiDragHandler(e) {
2107
+ if (currentTool !== 'select') return;
2108
+ e.preventDefault();
2109
+ e.stopPropagation();
2110
+
2111
+ const startCoords = getEventCoords(e);
2112
+ let lastX = startCoords.clientX;
2113
+ let lastY = startCoords.clientY;
2114
+
2115
+ multiSelectedAnnotations.forEach(el => el.classList.add('annotation-dragging'));
2116
+
2117
+ let multiDragRAF = null;
2118
+ let accDx = 0, accDy = 0;
2119
+ function onMove(ev) {
2120
+ ev.preventDefault();
2121
+ const moveCoords = getEventCoords(ev);
2122
+ accDx += moveCoords.clientX - lastX;
2123
+ accDy += moveCoords.clientY - lastY;
2124
+ lastX = moveCoords.clientX;
2125
+ lastY = moveCoords.clientY;
2126
+
2127
+ if (!multiDragRAF) {
2128
+ multiDragRAF = requestAnimationFrame(() => {
2129
+ multiDragRAF = null;
2130
+ const vbDelta = screenDeltaToViewBox(svg, accDx, accDy, multiSelectedAnnotations[0]);
2131
+ accDx = 0; accDy = 0;
2132
+ multiSelectedAnnotations.forEach(el => moveAnnotation(el, vbDelta.dx, vbDelta.dy));
2133
+ });
2134
+ }
2135
+ }
2136
+
2137
+ function onEnd() {
2138
+ document.removeEventListener('mousemove', onMove);
2139
+ document.removeEventListener('mouseup', onEnd);
2140
+ document.removeEventListener('touchmove', onMove);
2141
+ document.removeEventListener('touchend', onEnd);
2142
+ document.removeEventListener('touchcancel', onEnd);
2143
+
2144
+ multiSelectedAnnotations.forEach(el => el.classList.remove('annotation-dragging'));
2145
+
2146
+ // Clamp all selected annotations within page bounds
2147
+ const vbW = parseFloat(svg.dataset.viewboxWidth);
2148
+ const vbH = parseFloat(svg.dataset.viewboxHeight);
2149
+ multiSelectedAnnotations.forEach(el => clampAnnotationToPage(el, vbW, vbH));
2150
+
2151
+ if (svg.isConnected) saveAnnotations(pageNum);
2152
+ }
2153
+
2154
+ document.addEventListener('mousemove', onMove, { passive: false });
2155
+ document.addEventListener('mouseup', onEnd);
2156
+ document.addEventListener('touchmove', onMove, { passive: false });
2157
+ document.addEventListener('touchend', onEnd);
2158
+ document.addEventListener('touchcancel', onEnd);
2159
+ }
2160
+
2161
+ multiDragHandler = startMultiDragHandler;
2162
+ multiSelectedAnnotations.forEach(el => {
2163
+ el.style.cursor = 'grab';
2164
+ el.addEventListener('mousedown', startMultiDragHandler);
2165
+ el.addEventListener('touchstart', startMultiDragHandler, { passive: false });
2166
+ });
2167
+ }
2168
+
2169
+ // moveAnnotation - applies delta movement to an annotation element
2170
+ function moveAnnotation(element, dx, dy) {
2171
+ if (element.tagName === 'path') {
2172
+ // Transform path using translate
2173
+ const currentTransform = element.getAttribute('transform') || '';
2174
+ const match = currentTransform.match(/translate\(([^,]+),\s*([^)]+)\)/);
2175
+ let tx = 0, ty = 0;
2176
+ if (match) {
2177
+ tx = parseFloat(match[1]);
2178
+ ty = parseFloat(match[2]);
2179
+ }
2180
+ element.setAttribute('transform', `translate(${tx + dx}, ${ty + dy})`);
2181
+ } else if (element.tagName === 'rect') {
2182
+ element.setAttribute('x', parseFloat(element.getAttribute('x')) + dx);
2183
+ element.setAttribute('y', parseFloat(element.getAttribute('y')) + dy);
2184
+ } else if (element.tagName === 'ellipse') {
2185
+ element.setAttribute('cx', parseFloat(element.getAttribute('cx')) + dx);
2186
+ element.setAttribute('cy', parseFloat(element.getAttribute('cy')) + dy);
2187
+ } else if (element.tagName === 'line') {
2188
+ element.setAttribute('x1', parseFloat(element.getAttribute('x1')) + dx);
2189
+ element.setAttribute('y1', parseFloat(element.getAttribute('y1')) + dy);
2190
+ element.setAttribute('x2', parseFloat(element.getAttribute('x2')) + dx);
2191
+ element.setAttribute('y2', parseFloat(element.getAttribute('y2')) + dy);
2192
+ } else if (element.tagName === 'text') {
2193
+ element.setAttribute('x', parseFloat(element.getAttribute('x')) + dx);
2194
+ element.setAttribute('y', parseFloat(element.getAttribute('y')) + dy);
2195
+ }
2196
+ }
2197
+
2198
+ // Clamp annotation element within page viewBox bounds
2199
+ function clampAnnotationToPage(element, maxW, maxH) {
2200
+ const margin = 10;
2201
+ function clamp(val, min, max) { return Math.max(min, Math.min(val, max)); }
2202
+
2203
+ if (element.tagName === 'path') {
2204
+ const transform = element.getAttribute('transform') || '';
2205
+ const match = transform.match(/translate\(([^,]+),\s*([^)]+)\)/);
2206
+ if (match) {
2207
+ const tx = clamp(parseFloat(match[1]), -maxW + margin, maxW - margin);
2208
+ const ty = clamp(parseFloat(match[2]), -maxH + margin, maxH - margin);
2209
+ element.setAttribute('transform', `translate(${tx}, ${ty})`);
2210
+ }
2211
+ } else if (element.tagName === 'rect') {
2212
+ element.setAttribute('x', clamp(parseFloat(element.getAttribute('x')), 0, maxW - margin));
2213
+ element.setAttribute('y', clamp(parseFloat(element.getAttribute('y')), 0, maxH - margin));
2214
+ } else if (element.tagName === 'ellipse') {
2215
+ element.setAttribute('cx', clamp(parseFloat(element.getAttribute('cx')), margin, maxW - margin));
2216
+ element.setAttribute('cy', clamp(parseFloat(element.getAttribute('cy')), margin, maxH - margin));
2217
+ } else if (element.tagName === 'line') {
2218
+ element.setAttribute('x1', clamp(parseFloat(element.getAttribute('x1')), 0, maxW));
2219
+ element.setAttribute('y1', clamp(parseFloat(element.getAttribute('y1')), 0, maxH));
2220
+ element.setAttribute('x2', clamp(parseFloat(element.getAttribute('x2')), 0, maxW));
2221
+ element.setAttribute('y2', clamp(parseFloat(element.getAttribute('y2')), 0, maxH));
2222
+ } else if (element.tagName === 'text') {
2223
+ element.setAttribute('x', clamp(parseFloat(element.getAttribute('x')), 0, maxW - margin));
2224
+ element.setAttribute('y', clamp(parseFloat(element.getAttribute('y')), margin, maxH - margin));
2225
+ }
2226
+ }
2227
+
2228
+ // Legacy function for backwards compatibility (used elsewhere)
2229
+ function handleSelectMouseDown(e, svg, pageNum) {
2230
+ return handleSelectPointerDown(e, svg, pageNum);
2231
+ }
2232
+
2233
+ // ==========================================
2234
+ // KEYBOARD SHORTCUTS
2235
+ // ==========================================
2236
+ document.addEventListener('keydown', (e) => {
2237
+ // Ignore if typing in input
2238
+ if (e.target.tagName === 'INPUT' || e.target.contentEditable === 'true') return;
2239
+
2240
+ const key = e.key.toLowerCase();
2241
+
2242
+ // Tool shortcuts
2243
+ if (key === 'h') { setTool('highlight'); e.preventDefault(); }
2244
+ if (key === 'p') { setTool('pen'); e.preventDefault(); }
2245
+ if (key === 'e') { setTool('eraser'); e.preventDefault(); }
2246
+ if (key === 't') { setTool('text'); e.preventDefault(); }
2247
+ if (key === 'r') { setTool('shape'); e.preventDefault(); }
2248
+ if (key === 'v') { setTool('select'); e.preventDefault(); }
2249
+ if (key === 'f') { toggleFullscreen(); e.preventDefault(); }
2250
+
2251
+ // Delete selected annotation(s)
2252
+ if ((key === 'delete' || key === 'backspace') && (selectedAnnotation || multiSelectedAnnotations.length > 0)) {
2253
+ deleteSelectedAnnotation();
2254
+ e.preventDefault();
2255
+ }
2256
+
2257
+ // Undo/Redo
2258
+ if ((e.ctrlKey || e.metaKey) && key === 'z' && !e.shiftKey) {
2259
+ performUndo();
2260
+ e.preventDefault();
2261
+ return;
2262
+ }
2263
+ if ((e.ctrlKey || e.metaKey) && (key === 'y' || (key === 'z' && e.shiftKey))) {
2264
+ performRedo();
2265
+ e.preventDefault();
2266
+ return;
2267
+ }
2268
+
2269
+ // Copy/Paste annotations
2270
+ if ((e.ctrlKey || e.metaKey) && key === 'c' && selectedAnnotation) {
2271
+ copySelectedAnnotation();
2272
+ e.preventDefault();
2273
+ }
2274
+ if ((e.ctrlKey || e.metaKey) && key === 'v' && copiedAnnotation) {
2275
+ pasteAnnotation();
2276
+ e.preventDefault();
2277
+ }
2278
+
2279
+ // Navigation
2280
+ if (key === 's') {
2281
+ document.getElementById('sidebarBtn').click();
2282
+ e.preventDefault();
2283
+ }
2284
+
2285
+ // Arrow key navigation
2286
+ if (key === 'arrowleft' || key === 'arrowup') {
2287
+ if (pdfViewer && pdfViewer.currentPageNumber > 1) {
2288
+ pdfViewer.currentPageNumber--;
2289
+ }
2290
+ e.preventDefault();
2291
+ }
2292
+ if (key === 'arrowright' || key === 'arrowdown') {
2293
+ if (pdfViewer && pdfViewer.currentPageNumber < pdfViewer.pagesCount) {
2294
+ pdfViewer.currentPageNumber++;
2295
+ }
2296
+ e.preventDefault();
2297
+ }
2298
+
2299
+ // Home/End
2300
+ if (key === 'home') {
2301
+ if (pdfViewer) pdfViewer.currentPageNumber = 1;
2302
+ e.preventDefault();
2303
+ }
2304
+ if (key === 'end') {
2305
+ if (pdfViewer) pdfViewer.currentPageNumber = pdfViewer.pagesCount;
2306
+ e.preventDefault();
2307
+ }
2308
+
2309
+ // Zoom shortcuts - prevent browser zoom
2310
+ if ((e.ctrlKey || e.metaKey) && (key === '=' || key === '+' || e.code === 'Equal')) {
2311
+ e.preventDefault();
2312
+ e.stopPropagation();
2313
+ pdfViewer.currentScale += 0.25;
2314
+ return;
2315
+ }
2316
+ if ((e.ctrlKey || e.metaKey) && (key === '-' || e.code === 'Minus')) {
2317
+ e.preventDefault();
2318
+ e.stopPropagation();
2319
+ pdfViewer.currentScale -= 0.25;
2320
+ return;
2321
+ }
2322
+ if ((e.ctrlKey || e.metaKey) && (key === '0' || e.code === 'Digit0')) {
2323
+ e.preventDefault();
2324
+ e.stopPropagation();
2325
+ pdfViewer.currentScaleValue = 'page-width';
2326
+ return;
2327
+ }
2328
+
2329
+ // Escape to deselect tool
2330
+ if (key === 'escape') {
2331
+ if (currentTool) {
2332
+ setTool(currentTool); // Toggle off
2333
+ }
2334
+ closeAllDropdowns();
2335
+ }
2336
+
2337
+ // Sepia mode
2338
+ if (key === 'm') {
2339
+ document.getElementById('sepiaBtn').click();
2340
+ e.preventDefault();
2341
+ }
2342
+ });
2343
+
2344
+ // ==========================================
2345
+ // CONTEXT MENU (Right-click)
2346
+ // ==========================================
2347
+ const contextMenu = document.createElement('div');
2348
+ contextMenu.className = 'contextMenu';
2349
+ contextMenu.innerHTML = `
2350
+ <div class="contextMenuItem" data-action="highlight">
2351
+ <svg viewBox="0 0 24 24"><path d="M3 21h18v-2H3v2zM5 16h14l-3-10H8l-3 10z"/></svg>
2352
+ Vurgula
2353
+ <span class="shortcutHint">H</span>
2354
+ </div>
2355
+ <div class="contextMenuItem" data-action="pen">
2356
+ <svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
2357
+ Kalem
2358
+ <span class="shortcutHint">P</span>
2359
+ </div>
2360
+ <div class="contextMenuItem" data-action="text">
2361
+ <svg viewBox="0 0 24 24"><path d="M5 4v3h5.5v12h3V7H19V4H5z"/></svg>
2362
+ Metin Ekle
2363
+ <span class="shortcutHint">T</span>
2364
+ </div>
2365
+ <div class="contextMenuDivider"></div>
2366
+ <div class="contextMenuItem" data-action="zoomIn">
2367
+ <svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
2368
+ Yakınlaştır
2369
+ <span class="shortcutHint">Ctrl++</span>
2370
+ </div>
2371
+ <div class="contextMenuItem" data-action="zoomOut">
2372
+ <svg viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg>
2373
+ Uzaklaştır
2374
+ <span class="shortcutHint">Ctrl+-</span>
2375
+ </div>
2376
+ <div class="contextMenuDivider"></div>
2377
+ <div class="contextMenuItem" data-action="sepia">
2378
+ <svg viewBox="0 0 24 24"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z"/></svg>
2379
+ Okuma Modu
2380
+ <span class="shortcutHint">M</span>
2381
+ </div>
2382
+ `;
2383
+ document.body.appendChild(contextMenu);
2384
+
2385
+ // Show context menu on right-click in viewer
2386
+ function showCustomContextMenu(e) {
2387
+ e.preventDefault();
2388
+ contextMenu.style.left = e.clientX + 'px';
2389
+ contextMenu.style.top = e.clientY + 'px';
2390
+ contextMenu.classList.add('visible');
2391
+ }
2392
+ container.addEventListener('contextmenu', showCustomContextMenu);
2393
+
2394
+ // Hide context menu on click
2395
+ document.addEventListener('click', () => {
2396
+ contextMenu.classList.remove('visible');
2397
+ });
2398
+
2399
+ // Context menu actions
2400
+ contextMenu.addEventListener('click', (e) => {
2401
+ const item = e.target.closest('.contextMenuItem');
2402
+ if (!item) return;
2403
+
2404
+ const action = item.dataset.action;
2405
+ switch (action) {
2406
+ case 'highlight': setTool('highlight'); break;
2407
+ case 'pen': setTool('pen'); break;
2408
+ case 'text': setTool('text'); break;
2409
+ case 'zoomIn': pdfViewer.currentScale += 0.25; break;
2410
+ case 'zoomOut': pdfViewer.currentScale -= 0.25; break;
2411
+ case 'sepia': document.getElementById('sepiaBtn').click(); break;
2412
+ }
2413
+ contextMenu.classList.remove('visible');
2414
+ });
2415
+
2416
+ // ==========================================
2417
+ // ERGONOMIC FEATURES
2418
+ // ==========================================
2419
+
2420
+ // Fullscreen toggle function
2421
+ function toggleFullscreen() {
2422
+ if (document.fullscreenElement) {
2423
+ document.exitFullscreen();
2424
+ } else {
2425
+ document.documentElement.requestFullscreen().catch(() => { });
2426
+ }
2427
+ }
2428
+
2429
+ // Update fullscreen button icon
2430
+ function updateFullscreenIcon() {
2431
+ const icon = document.getElementById('fullscreenIcon');
2432
+ const btn = document.getElementById('fullscreenBtn');
2433
+ if (document.fullscreenElement) {
2434
+ icon.innerHTML = '<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>';
2435
+ btn.classList.add('active');
2436
+ } else {
2437
+ icon.innerHTML = '<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>';
2438
+ btn.classList.remove('active');
2439
+ }
2440
+ }
2441
+
2442
+ document.addEventListener('fullscreenchange', updateFullscreenIcon);
2443
+
2444
+ // Fullscreen button click
2445
+ document.getElementById('fullscreenBtn').onclick = () => toggleFullscreen();
2446
+
2447
+ // Double-click on page for fullscreen
2448
+ let lastClickTime = 0;
2449
+ container.addEventListener('click', (e) => {
2450
+ const now = Date.now();
2451
+ if (now - lastClickTime < 300) {
2452
+ toggleFullscreen();
2453
+ }
2454
+ lastClickTime = now;
2455
+ });
2456
+
2457
+ // Auto-fullscreen when viewer loads inside iframe
2458
+ if (window.self !== window.top && window.PDF_SECURE_CONFIG) {
2459
+ // We're inside an iframe - request fullscreen on first user interaction
2460
+ const autoFullscreen = () => {
2461
+ document.documentElement.requestFullscreen().catch(() => { });
2462
+ container.removeEventListener('click', autoFullscreen);
2463
+ container.removeEventListener('touchstart', autoFullscreen);
2464
+ };
2465
+ container.addEventListener('click', autoFullscreen, { once: true });
2466
+ container.addEventListener('touchstart', autoFullscreen, { once: true });
2467
+ }
2468
+
2469
+ // Mouse wheel zoom with Ctrl (debounced, clamped 0.5x-5x)
2470
+ let zoomTimeout;
2471
+ container.addEventListener('wheel', (e) => {
2472
+ if (!e.ctrlKey) return;
2473
+ e.preventDefault();
2474
+ clearTimeout(zoomTimeout);
2475
+ const delta = e.deltaY < 0 ? 0.1 : -0.1;
2476
+ zoomTimeout = setTimeout(() => {
2477
+ pdfViewer.currentScale = Math.max(0.5, Math.min(5, pdfViewer.currentScale + delta));
2478
+ }, 30);
2479
+ }, { passive: false });
2480
+
2481
+ console.log('PDF Viewer Ready');
2482
+ console.log('Keyboard Shortcuts: H=Highlight, P=Pen, E=Eraser, T=Text, R=Shapes, S=Sidebar, M=ReadingMode, Arrows=Navigate');
2483
+
2484
+ // ==========================================
2485
+ // MOBILE / TABLET SUPPORT
2486
+ // ==========================================
2487
+ const isMobile = () => window.innerWidth <= 599;
2488
+ const isTabletPortrait = () => {
2489
+ const w = window.innerWidth;
2490
+ return w >= 600 && w <= 1024 && window.innerHeight > window.innerWidth;
2491
+ };
2492
+ const isTouch = () => 'ontouchstart' in window || navigator.maxTouchPoints > 0;
2493
+
2494
+ // Bottom toolbar element references
2495
+ const bottomToolbarInner = document.getElementById('bottomToolbarInner');
2496
+
2497
+ // Elements to move between top toolbar and bottom toolbar on mobile
2498
+ // We identify the annotation tools group (highlighter, pen, eraser, select, separator, undo, redo, clearAll, separator, text, shapes)
2499
+ const annotationToolsSelector = '#toolbar > .toolbarGroup:nth-child(3)';
2500
+ let toolsMovedToBottom = false;
2501
+ let annotationToolsPlaceholder = null;
2502
+
2503
+ function setupResponsiveToolbar() {
2504
+ const needsBottomBar = isMobile() || isTabletPortrait();
2505
+
2506
+ if (needsBottomBar && !toolsMovedToBottom) {
2507
+ // Move annotation tools to bottom toolbar
2508
+ const annotationGroup = document.querySelector(annotationToolsSelector);
2509
+ if (annotationGroup && bottomToolbarInner) {
2510
+ // Create placeholder to remember position
2511
+ annotationToolsPlaceholder = document.createComment('annotation-tools-placeholder');
2512
+ annotationGroup.parentNode.insertBefore(annotationToolsPlaceholder, annotationGroup);
2513
+
2514
+ // Move children into bottom toolbar
2515
+ while (annotationGroup.firstChild) {
2516
+ bottomToolbarInner.appendChild(annotationGroup.firstChild);
2517
+ }
2518
+ // Hide empty group
2519
+ annotationGroup.style.display = 'none';
2520
+ toolsMovedToBottom = true;
2521
+ }
2522
+ } else if (!needsBottomBar && toolsMovedToBottom) {
2523
+ // Move tools back to top toolbar
2524
+ const annotationGroup = document.querySelector(annotationToolsSelector);
2525
+ if (annotationGroup && bottomToolbarInner && annotationToolsPlaceholder) {
2526
+ while (bottomToolbarInner.firstChild) {
2527
+ annotationGroup.appendChild(bottomToolbarInner.firstChild);
2528
+ }
2529
+ annotationGroup.style.display = '';
2530
+ toolsMovedToBottom = false;
2531
+ }
2532
+ }
2533
+ }
2534
+
2535
+ // Run on load
2536
+ setupResponsiveToolbar();
2537
+
2538
+ // Use matchMedia for responsive switching
2539
+ const mobileMediaQuery = window.matchMedia('(max-width: 599px)');
2540
+ mobileMediaQuery.addEventListener('change', () => {
2541
+ setupResponsiveToolbar();
2542
+ });
2543
+
2544
+ const tabletPortraitQuery = window.matchMedia(
2545
+ '(min-width: 600px) and (max-width: 1024px) and (orientation: portrait)'
2546
+ );
2547
+ tabletPortraitQuery.addEventListener('change', () => {
2548
+ setupResponsiveToolbar();
2549
+ });
2550
+
2551
+ // Also handle resize for orientation changes
2552
+ window.addEventListener('resize', () => {
2553
+ setupResponsiveToolbar();
2554
+ });
2555
+
2556
+ // ==========================================
2557
+ // PINCH-TO-ZOOM (Touch devices)
2558
+ // ==========================================
2559
+ let pinchStartDistance = 0;
2560
+ let pinchStartScale = 1;
2561
+ let isPinching = false;
2562
+
2563
+ function getTouchDistance(touches) {
2564
+ const dx = touches[0].clientX - touches[1].clientX;
2565
+ const dy = touches[0].clientY - touches[1].clientY;
2566
+ return Math.sqrt(dx * dx + dy * dy);
2567
+ }
2568
+
2569
+ container.addEventListener('touchstart', (e) => {
2570
+ if (e.touches.length === 2 && !currentTool) {
2571
+ isPinching = true;
2572
+ pinchStartDistance = getTouchDistance(e.touches);
2573
+ pinchStartScale = pdfViewer.currentScale;
2574
+ e.preventDefault();
2575
+ }
2576
+ }, { passive: false });
2577
+
2578
+ container.addEventListener('touchmove', (e) => {
2579
+ if (isPinching && e.touches.length === 2) {
2580
+ const dist = getTouchDistance(e.touches);
2581
+ const ratio = dist / pinchStartDistance;
2582
+ const newScale = Math.min(Math.max(pinchStartScale * ratio, 0.5), 5.0);
2583
+ pdfViewer.currentScale = newScale;
2584
+ e.preventDefault();
2585
+ }
2586
+ }, { passive: false });
2587
+
2588
+ container.addEventListener('touchend', (e) => {
2589
+ if (e.touches.length < 2) {
2590
+ isPinching = false;
2591
+ }
2592
+ });
2593
+
2594
+ // ==========================================
2595
+ // CONTEXT MENU TOUCH HANDLING
2596
+ // ==========================================
2597
+ // On pure touch devices (no fine pointer), don't show custom context menu
2598
+ if (isTouch() && !window.matchMedia('(pointer: fine)').matches) {
2599
+ container.removeEventListener('contextmenu', showCustomContextMenu);
2600
+ }
2601
+
2602
+ // ==========================================
2603
+ // SECURITY FEATURES
2604
+ // ==========================================
2605
+
2606
+ (function initSecurityFeatures() {
2607
+ // 1. Block dangerous keyboard shortcuts (consolidated)
2608
+ const blockedCtrlKeys = new Set(['s', 'p', 'u']);
2609
+ const blockedCtrlShiftKeys = new Set(['s', 'i', 'j', 'c']);
2610
+ document.addEventListener('keydown', function (e) {
2611
+ const key = e.key.toLowerCase();
2612
+ if (e.key === 'F12') { e.preventDefault(); return; }
2613
+ if (e.ctrlKey && e.shiftKey && blockedCtrlShiftKeys.has(key)) { e.preventDefault(); return; }
2614
+ if (e.ctrlKey && !e.shiftKey && blockedCtrlKeys.has(key)) { e.preventDefault(); return; }
2615
+ }, true);
2616
+
2617
+ // 2. Block context menu (right-click) - skip annotation layer custom menu
2618
+ document.addEventListener('contextmenu', function (e) {
2619
+ if (e.target.closest('.annotationLayer')) return;
2620
+ e.preventDefault();
2621
+ }, true);
2622
+
2623
+ // 3. Block copy/cut
2624
+ document.addEventListener('copy', (e) => { e.preventDefault(); }, true);
2625
+ document.addEventListener('cut', (e) => { e.preventDefault(); }, true);
2626
+
2627
+ // 4. Block drag events
2628
+ document.addEventListener('dragstart', (e) => { e.preventDefault(); }, true);
2629
+
2630
+ // 5. Block Print
2631
+ window.print = function () {
2632
+ alert('Yazdırma bu belgede engellenmiştir.');
2633
+ };
2634
+
2635
+ // 6. Print event protection
2636
+ window.addEventListener('beforeprint', () => { document.body.style.display = 'none'; });
2637
+ window.addEventListener('afterprint', () => { document.body.style.display = ''; });
2638
+ })();
2639
+
2640
+ // End of main IIFE - pdfDoc, pdfViewer not accessible from console
2641
+ })();