nodebb-plugin-pdf-secure2 1.2.30

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