nodebb-plugin-pdf-secure 1.2.10 → 1.2.12

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.
@@ -37,11 +37,9 @@ Controllers.servePdfBinary = async function (req, res) {
37
37
  return res.status(400).json({ error: 'Missing nonce' });
38
38
  }
39
39
 
40
- if (!req.uid) {
41
- return res.status(401).json({ error: 'Not authenticated' });
42
- }
40
+ const uid = req.uid || 0; // Guest uid = 0
43
41
 
44
- const data = nonceStore.validate(nonce, req.uid);
42
+ const data = nonceStore.validate(nonce, uid);
45
43
  if (!data) {
46
44
  return res.status(403).json({ error: 'Invalid or expired nonce' });
47
45
  }
package/library.js CHANGED
@@ -36,21 +36,32 @@ plugin.init = async (params) => {
36
36
 
37
37
  // PDF direct access blocker middleware
38
38
  // Intercepts requests to uploaded PDF files and returns 403
39
- router.get('/assets/uploads/files/:filename', (req, res, next) => {
39
+ // Admin and Global Moderators can bypass this restriction
40
+ router.get('/assets/uploads/files/:filename', async (req, res, next) => {
40
41
  if (req.params.filename && req.params.filename.toLowerCase().endsWith('.pdf')) {
42
+ // Admin ve Global Mod'lar direkt erişebilsin
43
+ if (req.uid) {
44
+ const [isAdmin, isGlobalMod] = await Promise.all([
45
+ groups.isMember(req.uid, 'administrators'),
46
+ groups.isMember(req.uid, 'Global Moderators'),
47
+ ]);
48
+ if (isAdmin || isGlobalMod) {
49
+ return next();
50
+ }
51
+ }
41
52
  return res.status(403).json({ error: 'Direct PDF access is not allowed. Use the secure viewer.' });
42
53
  }
43
54
  next();
44
55
  });
45
56
 
46
- // PDF binary endpoint (nonce-validated)
47
- router.get('/api/v3/plugins/pdf-secure/pdf-data', middleware.ensureLoggedIn, controllers.servePdfBinary);
57
+ // PDF binary endpoint (nonce-validated, guests allowed)
58
+ router.get('/api/v3/plugins/pdf-secure/pdf-data', controllers.servePdfBinary);
48
59
 
49
60
  // Admin page route
50
61
  routeHelpers.setupAdminPageRoute(router, '/admin/plugins/pdf-secure', controllers.renderAdminPage);
51
62
 
52
- // Viewer page route (fullscreen Mozilla PDF.js viewer)
53
- router.get('/plugins/pdf-secure/viewer', middleware.ensureLoggedIn, (req, res) => {
63
+ // Viewer page route (fullscreen Mozilla PDF.js viewer, guests allowed)
64
+ router.get('/plugins/pdf-secure/viewer', (req, res) => {
54
65
  const { file } = req.query;
55
66
  if (!file) {
56
67
  return res.status(400).send('Missing file parameter');
@@ -70,7 +81,7 @@ plugin.init = async (params) => {
70
81
  // Generate nonce + key HERE (in viewer route)
71
82
  // This way the key is ONLY embedded in HTML, never in a separate API response
72
83
  const isPremium = true;
73
- const nonceData = nonceStore.generate(req.uid, safeName, isPremium);
84
+ const nonceData = nonceStore.generate(req.uid || 0, safeName, isPremium);
74
85
 
75
86
  // Serve the viewer template with comprehensive security headers
76
87
  res.set({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-pdf-secure",
3
- "version": "1.2.10",
3
+ "version": "1.2.12",
4
4
  "description": "Secure PDF viewer plugin for NodeBB - prevents downloading, enables canvas-only rendering with Premium group support",
5
5
  "main": "library.js",
6
6
  "repository": {
@@ -215,6 +215,8 @@
215
215
  targetElement.replaceWith(container);
216
216
 
217
217
  // LAZY LOADING with Intersection Observer + Queue
218
+ // Smart loading: only loads PDFs that are actually visible
219
+ var queueEntry = null; // Track if this PDF is in queue
218
220
  var observer = new IntersectionObserver(function (entries) {
219
221
  entries.forEach(function (entry) {
220
222
  if (entry.isIntersecting) {
@@ -229,13 +231,36 @@
229
231
  svgEl.innerHTML = '<path d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8z"/>';
230
232
  }
231
233
 
232
- // Add to queue
233
- queuePdfLoad(iframeWrapper, filename, loadingPlaceholder);
234
- observer.disconnect();
234
+ // Add to queue (if not already)
235
+ if (!queueEntry) {
236
+ queueEntry = { wrapper: iframeWrapper, filename, placeholder: loadingPlaceholder };
237
+ loadQueue.push(queueEntry);
238
+ processQueue();
239
+ }
240
+ } else {
241
+ // LEFT viewport - remove from queue if waiting
242
+ if (queueEntry && loadQueue.includes(queueEntry)) {
243
+ var idx = loadQueue.indexOf(queueEntry);
244
+ if (idx > -1) {
245
+ loadQueue.splice(idx, 1);
246
+ console.log('[PDF-Secure] Queue: Removed (left viewport) -', filename);
247
+
248
+ // Reset placeholder to waiting state
249
+ var textEl = loadingPlaceholder.querySelector('.pdf-loading-text');
250
+ if (textEl) textEl.textContent = 'Sırada bekliyor...';
251
+ var svgEl = loadingPlaceholder.querySelector('svg');
252
+ if (svgEl) {
253
+ svgEl.style.fill = '#555';
254
+ svgEl.style.animation = 'none';
255
+ svgEl.innerHTML = '<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 18H6V4h7v5h5v11z"/>';
256
+ }
257
+ }
258
+ queueEntry = null;
259
+ }
235
260
  }
236
261
  });
237
262
  }, {
238
- rootMargin: '200px',
263
+ rootMargin: '0px', // Only trigger when actually visible
239
264
  threshold: 0
240
265
  });
241
266
 
@@ -442,22 +442,46 @@
442
442
  font-size: 14px;
443
443
  white-space: nowrap;
444
444
  }
445
- .overflowItem:hover { background: var(--bg-tertiary); }
446
- .overflowItem svg { width: 20px; height: 20px; fill: currentColor; flex-shrink: 0; }
447
- .overflowItem.active { color: var(--accent); }
448
- .overflowDivider { height: 1px; background: var(--border-color); margin: 6px 0; }
445
+
446
+ .overflowItem:hover {
447
+ background: var(--bg-tertiary);
448
+ }
449
+
450
+ .overflowItem svg {
451
+ width: 20px;
452
+ height: 20px;
453
+ fill: currentColor;
454
+ flex-shrink: 0;
455
+ }
456
+
457
+ .overflowItem.active {
458
+ color: var(--accent);
459
+ }
460
+
461
+ .overflowDivider {
462
+ height: 1px;
463
+ background: var(--border-color);
464
+ margin: 6px 0;
465
+ }
449
466
 
450
467
  /* Overflow: visible on all screens, originals hidden */
451
- #overflowWrapper { display: flex; }
452
- .overflowSep { display: block; }
468
+ #overflowWrapper {
469
+ display: flex;
470
+ }
471
+
472
+ .overflowSep {
473
+ display: block;
474
+ }
453
475
 
454
476
  /* Hide rotate, sepia and their separators (children 3-8 of view group) */
455
- .toolbarGroup:nth-child(5) > :nth-child(3),
456
- .toolbarGroup:nth-child(5) > :nth-child(4),
457
- .toolbarGroup:nth-child(5) > :nth-child(5),
458
- .toolbarGroup:nth-child(5) > :nth-child(6),
459
- .toolbarGroup:nth-child(5) > :nth-child(7),
460
- .toolbarGroup:nth-child(5) > :nth-child(8) { display: none !important; }
477
+ .toolbarGroup:nth-child(5)> :nth-child(3),
478
+ .toolbarGroup:nth-child(5)> :nth-child(4),
479
+ .toolbarGroup:nth-child(5)> :nth-child(5),
480
+ .toolbarGroup:nth-child(5)> :nth-child(6),
481
+ .toolbarGroup:nth-child(5)> :nth-child(7),
482
+ .toolbarGroup:nth-child(5)> :nth-child(8) {
483
+ display: none !important;
484
+ }
461
485
 
462
486
  /* Shape Grid */
463
487
  .shapeGrid {
@@ -1263,10 +1287,12 @@
1263
1287
  display: block !important;
1264
1288
  min-width: unset;
1265
1289
  box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.4);
1290
+ pointer-events: none;
1266
1291
  }
1267
1292
 
1268
1293
  .toolDropdown.visible {
1269
1294
  transform: translateY(0);
1295
+ pointer-events: auto;
1270
1296
  }
1271
1297
 
1272
1298
  /* Responsive dropzone */
@@ -1430,10 +1456,12 @@
1430
1456
  display: block !important;
1431
1457
  min-width: unset;
1432
1458
  box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.4);
1459
+ pointer-events: none;
1433
1460
  }
1434
1461
 
1435
1462
  .toolDropdown.visible {
1436
1463
  transform: translateY(0);
1464
+ pointer-events: auto;
1437
1465
  }
1438
1466
 
1439
1467
  /* Selection toolbar & toast above bottom bar */
@@ -1785,23 +1813,32 @@
1785
1813
  <div class="toolbarBtnWithDropdown" id="overflowWrapper">
1786
1814
  <button class="toolbarBtn" id="overflowBtn" title="Daha Fazla">
1787
1815
  <svg viewBox="0 0 24 24">
1788
- <circle cx="12" cy="5" r="2"/>
1789
- <circle cx="12" cy="12" r="2"/>
1790
- <circle cx="12" cy="19" r="2"/>
1816
+ <circle cx="12" cy="5" r="2" />
1817
+ <circle cx="12" cy="12" r="2" />
1818
+ <circle cx="12" cy="19" r="2" />
1791
1819
  </svg>
1792
1820
  </button>
1793
1821
  <div class="toolDropdown" id="overflowDropdown">
1794
1822
  <button class="overflowItem" id="overflowRotateLeft">
1795
- <svg viewBox="0 0 24 24"><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
1823
+ <svg viewBox="0 0 24 24">
1824
+ <path
1825
+ d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z" />
1826
+ </svg>
1796
1827
  <span>Sola Döndür</span>
1797
1828
  </button>
1798
1829
  <button class="overflowItem" id="overflowRotateRight">
1799
- <svg viewBox="0 0 24 24"><path d="M15.55 5.55L11 1v3.07C7.06 4.56 4 7.92 4 12s3.05 7.44 7 7.93v-2.02c-2.84-.48-5-2.94-5-5.91s2.16-5.43 5-5.91V10l4.55-4.45zM19.93 11c-.17-1.39-.72-2.73-1.62-3.89l-1.42 1.42c.54.75.88 1.6 1.02 2.47h2.02zM13 17.9v2.02c1.39-.17 2.74-.71 3.9-1.61l-1.44-1.44c-.75.54-1.59.89-2.46 1.03zm3.89-2.42l1.42 1.41c.9-1.16 1.45-2.5 1.62-3.89h-2.02c-.14.87-.48 1.72-1.02 2.48z"/></svg>
1830
+ <svg viewBox="0 0 24 24">
1831
+ <path
1832
+ d="M15.55 5.55L11 1v3.07C7.06 4.56 4 7.92 4 12s3.05 7.44 7 7.93v-2.02c-2.84-.48-5-2.94-5-5.91s2.16-5.43 5-5.91V10l4.55-4.45zM19.93 11c-.17-1.39-.72-2.73-1.62-3.89l-1.42 1.42c.54.75.88 1.6 1.02 2.47h2.02zM13 17.9v2.02c1.39-.17 2.74-.71 3.9-1.61l-1.44-1.44c-.75.54-1.59.89-2.46 1.03zm3.89-2.42l1.42 1.41c.9-1.16 1.45-2.5 1.62-3.89h-2.02c-.14.87-.48 1.72-1.02 2.48z" />
1833
+ </svg>
1800
1834
  <span>Sağa Döndür</span>
1801
1835
  </button>
1802
1836
  <div class="overflowDivider"></div>
1803
1837
  <button class="overflowItem" id="overflowSepia">
1804
- <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 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
1838
+ <svg viewBox="0 0 24 24">
1839
+ <path
1840
+ 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 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
1841
+ </svg>
1805
1842
  <span>Okuma Modu</span>
1806
1843
  </button>
1807
1844
  </div>
@@ -1896,6 +1933,10 @@
1896
1933
  let currentPath = null;
1897
1934
  let currentDrawingPage = null;
1898
1935
 
1936
+ // RAF throttle for smooth drawing performance
1937
+ let pathSegments = []; // Buffer path segments
1938
+ let drawRAF = null; // requestAnimationFrame ID
1939
+
1899
1940
  // Annotation persistence - stores SVG innerHTML per page
1900
1941
  const annotationsStore = new Map();
1901
1942
  const annotationRotations = new Map(); // tracks rotation when annotations were saved
@@ -2230,7 +2271,7 @@
2230
2271
  });
2231
2272
 
2232
2273
  eventBus.on('pagerendered', (evt) => {
2233
- if (annotationMode) injectAnnotationLayer(evt.pageNumber);
2274
+ injectAnnotationLayer(evt.pageNumber);
2234
2275
 
2235
2276
  // Rotation is handled natively by PDF.js via pagesRotation
2236
2277
  });
@@ -2313,7 +2354,17 @@
2313
2354
  const dropdownBackdrop = document.getElementById('dropdownBackdrop');
2314
2355
  const overflowDropdown = document.getElementById('overflowDropdown');
2315
2356
 
2357
+ // Swipe-to-dismiss controller (declared before closeAllDropdowns which references it)
2358
+ let swipeAbortController = null;
2359
+
2316
2360
  function closeAllDropdowns() {
2361
+ // Clean up swipe listeners
2362
+ if (swipeAbortController) { swipeAbortController.abort(); swipeAbortController = null; }
2363
+ // Reset inline transform from swipe gesture
2364
+ [highlightDropdown, drawDropdown, shapesDropdown, overflowDropdown].forEach(dd => {
2365
+ dd.style.transform = '';
2366
+ dd.style.transition = '';
2367
+ });
2317
2368
  highlightDropdown.classList.remove('visible');
2318
2369
  drawDropdown.classList.remove('visible');
2319
2370
  shapesDropdown.classList.remove('visible');
@@ -2337,10 +2388,52 @@
2337
2388
  // Show backdrop on mobile/tablet portrait
2338
2389
  if (useBottomSheet) {
2339
2390
  dropdownBackdrop.classList.add('visible');
2391
+ setupBottomSheetSwipe(dropdown);
2340
2392
  }
2341
2393
  }
2342
2394
  }
2343
2395
 
2396
+ // Bottom sheet swipe-to-dismiss (uses AbortController to prevent listener accumulation)
2397
+ function setupBottomSheetSwipe(dropdown) {
2398
+ // Abort previous swipe listeners if any
2399
+ if (swipeAbortController) swipeAbortController.abort();
2400
+ swipeAbortController = new AbortController();
2401
+ const signal = swipeAbortController.signal;
2402
+
2403
+ let startY = 0, currentY = 0, isDragging = false;
2404
+
2405
+ dropdown.addEventListener('touchstart', (e) => {
2406
+ const rect = dropdown.getBoundingClientRect();
2407
+ const touchY = e.touches[0].clientY;
2408
+ if (touchY - rect.top > 40 && dropdown.scrollTop > 0) return;
2409
+ startY = touchY;
2410
+ currentY = startY;
2411
+ isDragging = true;
2412
+ dropdown.style.transition = 'none';
2413
+ }, { signal });
2414
+
2415
+ dropdown.addEventListener('touchmove', (e) => {
2416
+ if (!isDragging) return;
2417
+ currentY = e.touches[0].clientY;
2418
+ const dy = currentY - startY;
2419
+ if (dy > 0) {
2420
+ dropdown.style.transform = `translateY(${dy}px)`;
2421
+ e.preventDefault();
2422
+ }
2423
+ }, { passive: false, signal });
2424
+
2425
+ dropdown.addEventListener('touchend', () => {
2426
+ if (!isDragging) return;
2427
+ isDragging = false;
2428
+ const dy = currentY - startY;
2429
+ dropdown.style.transition = '';
2430
+ if (dy > 80) {
2431
+ closeAllDropdowns();
2432
+ }
2433
+ dropdown.style.transform = '';
2434
+ }, { signal });
2435
+ }
2436
+
2344
2437
  // Backdrop click closes dropdowns
2345
2438
  dropdownBackdrop.addEventListener('click', () => {
2346
2439
  closeAllDropdowns();
@@ -2406,34 +2499,11 @@
2406
2499
  currentWidth = shapeWidth;
2407
2500
  }
2408
2501
 
2409
- // BUGFIX: Save current annotation state BEFORE re-injecting layers
2410
- // This prevents deleted content from being restored when switching tools
2411
- // Uses getCleanSvgInnerHTML to strip transient classes/styles
2412
- for (let i = 0; i < pdfViewer.pagesCount; i++) {
2413
- const pageView = pdfViewer.getPageView(i);
2414
- const svg = pageView?.div?.querySelector('.annotationLayer');
2415
- if (svg) {
2416
- const pageNum = i + 1;
2417
- const cleanHTML = getCleanSvgInnerHTML(svg);
2418
- if (cleanHTML) {
2419
- annotationsStore.set(pageNum, cleanHTML);
2420
- annotationRotations.set(pageNum, pdfViewer.pagesRotation || 0);
2421
- } else {
2422
- annotationsStore.delete(pageNum);
2423
- annotationRotations.delete(pageNum);
2424
- }
2425
- }
2426
- }
2427
-
2428
- // Inject annotation layers (await all)
2429
- const promises = [];
2430
- for (let i = 0; i < pdfViewer.pagesCount; i++) {
2431
- const pageView = pdfViewer.getPageView(i);
2432
- if (pageView?.div) {
2433
- promises.push(injectAnnotationLayer(i + 1));
2434
- }
2435
- }
2436
- await Promise.all(promises);
2502
+ // Performance: Just toggle active class instead of re-injecting layers
2503
+ // This avoids expensive DOM recreation on every tool change
2504
+ document.querySelectorAll('.annotationLayer').forEach(layer => {
2505
+ layer.classList.toggle('active', annotationMode);
2506
+ });
2437
2507
  }
2438
2508
 
2439
2509
  // Update button states
@@ -2602,18 +2672,8 @@
2602
2672
  annotationsStore.set(pageNum, svg.innerHTML);
2603
2673
  annotationRotations.set(pageNum, curRot);
2604
2674
 
2605
- // Transform undo/redo stack entries to match new rotation
2606
- const wrapStackEntries = (stackMap) => {
2607
- const entries = stackMap.get(pageNum);
2608
- if (!entries) return;
2609
- for (let i = 0; i < entries.length; i++) {
2610
- if (entries[i].trim()) {
2611
- entries[i] = `<g transform="${transform}">${entries[i]}</g>`;
2612
- }
2613
- }
2614
- };
2615
- wrapStackEntries(undoStacks);
2616
- wrapStackEntries(redoStacks);
2675
+ // Note: No need to wrap stack entries anymore
2676
+ // Rotation is now stored per-entry, transforms applied on restore
2617
2677
  }
2618
2678
  }
2619
2679
 
@@ -2645,13 +2705,16 @@
2645
2705
 
2646
2706
  // Strip transient classes, styles, and elements from SVG before saving
2647
2707
  function getCleanSvgInnerHTML(svg) {
2708
+ // Performance: Work on a cloned node to avoid modifying live DOM
2709
+ const clone = svg.cloneNode(true);
2710
+
2648
2711
  // Remove marquee rect if present
2649
- const marquee = svg.querySelector('.marquee-rect');
2712
+ const marquee = clone.querySelector('.marquee-rect');
2650
2713
  if (marquee) marquee.remove();
2651
2714
 
2652
2715
  // Strip transient classes and inline styles from annotation elements
2653
2716
  const transientClasses = ['annotation-selected', 'annotation-multi-selected', 'annotation-dragging', 'just-selected'];
2654
- svg.querySelectorAll('path, rect, ellipse, line, text').forEach(el => {
2717
+ clone.querySelectorAll('path, rect, ellipse, line, text').forEach(el => {
2655
2718
  transientClasses.forEach(cls => el.classList.remove(cls));
2656
2719
  // Remove inline cursor style added by multi-drag
2657
2720
  if (el.style.cursor) el.style.cursor = '';
@@ -2661,7 +2724,50 @@
2661
2724
  if (el.getAttribute('class') === '') el.removeAttribute('class');
2662
2725
  });
2663
2726
 
2664
- return svg.innerHTML.trim();
2727
+ return clone.innerHTML.trim();
2728
+ }
2729
+
2730
+ // Helper: Apply rotation transform from savedRot to curRot
2731
+ // Uses clone-based flatten approach - updates each element's transform individually
2732
+ // This prevents nested <g> accumulation entirely
2733
+ function applyRotationTransform(html, savedRot, curRot, pageNum) {
2734
+ if (!html || !html.trim()) return html;
2735
+
2736
+ const delta = (curRot - savedRot + 360) % 360;
2737
+ if (delta === 0) return html;
2738
+
2739
+ // Calculate transform based on page dimensions
2740
+ const baseDims = pageBaseDimensions.get(pageNum);
2741
+ if (!baseDims) return html; // Fallback if no dims available
2742
+
2743
+ const W = baseDims.width, H = baseDims.height;
2744
+
2745
+ // Old viewBox dimensions (at saved rotation)
2746
+ let oW, oH;
2747
+ if (savedRot === 90 || savedRot === 270) { oW = H; oH = W; }
2748
+ else { oW = W; oH = H; }
2749
+
2750
+ let rotationTransform;
2751
+ if (delta === 90) rotationTransform = `translate(${oH},0) rotate(90)`;
2752
+ else if (delta === 180) rotationTransform = `translate(${oW},${oH}) rotate(180)`;
2753
+ else if (delta === 270) rotationTransform = `translate(0,${oW}) rotate(270)`;
2754
+ else return html;
2755
+
2756
+ // Clone-based flatten: Apply transform to each top-level element individually
2757
+ const tempContainer = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
2758
+ tempContainer.innerHTML = html;
2759
+
2760
+ // Process each top-level child element
2761
+ Array.from(tempContainer.children).forEach(child => {
2762
+ const existingTransform = child.getAttribute('transform') || '';
2763
+ // Prepend rotation transform (rotation first, then existing)
2764
+ const newTransform = existingTransform
2765
+ ? `${rotationTransform} ${existingTransform}`
2766
+ : rotationTransform;
2767
+ child.setAttribute('transform', newTransform);
2768
+ });
2769
+
2770
+ return tempContainer.innerHTML;
2665
2771
  }
2666
2772
 
2667
2773
  // Save annotations for a page (with undo history)
@@ -2678,7 +2784,8 @@
2678
2784
  if (previousState !== newState) {
2679
2785
  if (!undoStacks.has(pageNum)) undoStacks.set(pageNum, []);
2680
2786
  const stack = undoStacks.get(pageNum);
2681
- stack.push(previousState);
2787
+ // Store as {html, rotation} object to avoid nested <g> wrap accumulation
2788
+ stack.push({ html: previousState, rotation: annotationRotations.get(pageNum) || 0 });
2682
2789
  if (stack.length > MAX_HISTORY) stack.shift();
2683
2790
 
2684
2791
  // Clear redo stack on new action
@@ -2715,20 +2822,24 @@
2715
2822
  const svg = pageView?.div?.querySelector('.annotationLayer');
2716
2823
  if (!svg) return;
2717
2824
 
2718
- // Save current state to redo stack (clean)
2825
+ // Save current state to redo stack with rotation
2719
2826
  if (!redoStacks.has(pageNum)) redoStacks.set(pageNum, []);
2720
2827
  const redoStack = redoStacks.get(pageNum);
2721
- redoStack.push(getCleanSvgInnerHTML(svg));
2828
+ redoStack.push({ html: getCleanSvgInnerHTML(svg), rotation: pdfViewer.pagesRotation || 0 });
2722
2829
  if (redoStack.length > MAX_HISTORY) redoStack.shift();
2723
2830
 
2724
- // Restore previous state
2725
- const previousState = stack.pop();
2726
- svg.innerHTML = previousState;
2831
+ // Restore previous state with rotation transform if needed
2832
+ const entry = stack.pop();
2833
+ const previousHtml = typeof entry === 'object' ? entry.html : entry;
2834
+ const savedRot = typeof entry === 'object' ? entry.rotation : (annotationRotations.get(pageNum) || 0);
2835
+ const curRot = pdfViewer.pagesRotation || 0;
2836
+
2837
+ svg.innerHTML = applyRotationTransform(previousHtml, savedRot, curRot, pageNum);
2727
2838
 
2728
2839
  // Update store
2729
- if (previousState.trim()) {
2730
- annotationsStore.set(pageNum, previousState);
2731
- annotationRotations.set(pageNum, pdfViewer.pagesRotation || 0);
2840
+ if (previousHtml.trim()) {
2841
+ annotationsStore.set(pageNum, svg.innerHTML);
2842
+ annotationRotations.set(pageNum, curRot);
2732
2843
  } else {
2733
2844
  annotationsStore.delete(pageNum);
2734
2845
  annotationRotations.delete(pageNum);
@@ -2747,20 +2858,24 @@
2747
2858
  const svg = pageView?.div?.querySelector('.annotationLayer');
2748
2859
  if (!svg) return;
2749
2860
 
2750
- // Save current state to undo stack (clean)
2861
+ // Save current state to undo stack with rotation
2751
2862
  if (!undoStacks.has(pageNum)) undoStacks.set(pageNum, []);
2752
2863
  const undoStack = undoStacks.get(pageNum);
2753
- undoStack.push(getCleanSvgInnerHTML(svg));
2864
+ undoStack.push({ html: getCleanSvgInnerHTML(svg), rotation: pdfViewer.pagesRotation || 0 });
2754
2865
  if (undoStack.length > MAX_HISTORY) undoStack.shift();
2755
2866
 
2756
- // Restore redo state
2757
- const redoState = stack.pop();
2758
- svg.innerHTML = redoState;
2867
+ // Restore redo state with rotation transform if needed
2868
+ const entry = stack.pop();
2869
+ const redoHtml = typeof entry === 'object' ? entry.html : entry;
2870
+ const savedRot = typeof entry === 'object' ? entry.rotation : (annotationRotations.get(pageNum) || 0);
2871
+ const curRot = pdfViewer.pagesRotation || 0;
2872
+
2873
+ svg.innerHTML = applyRotationTransform(redoHtml, savedRot, curRot, pageNum);
2759
2874
 
2760
2875
  // Update store
2761
- if (redoState.trim()) {
2762
- annotationsStore.set(pageNum, redoState);
2763
- annotationRotations.set(pageNum, pdfViewer.pagesRotation || 0);
2876
+ if (redoHtml.trim()) {
2877
+ annotationsStore.set(pageNum, svg.innerHTML);
2878
+ annotationRotations.set(pageNum, curRot);
2764
2879
  } else {
2765
2880
  annotationsStore.delete(pageNum);
2766
2881
  annotationRotations.delete(pageNum);
@@ -2776,10 +2891,10 @@
2776
2891
  const svg = pageView?.div?.querySelector('.annotationLayer');
2777
2892
  if (!svg || !svg.innerHTML.trim()) return;
2778
2893
 
2779
- // Save current state to undo stack (so it can be undone)
2894
+ // Save current state to undo stack with rotation
2780
2895
  if (!undoStacks.has(pageNum)) undoStacks.set(pageNum, []);
2781
2896
  const stack = undoStacks.get(pageNum);
2782
- stack.push(svg.innerHTML);
2897
+ stack.push({ html: svg.innerHTML, rotation: pdfViewer.pagesRotation || 0 });
2783
2898
  if (stack.length > MAX_HISTORY) stack.shift();
2784
2899
 
2785
2900
  // Clear redo stack
@@ -2822,8 +2937,8 @@
2822
2937
  const scaleY = vb.scaleY;
2823
2938
 
2824
2939
  if (currentTool === 'eraser') {
2825
- eraseAt(svg, x, y, scaleX);
2826
- saveAnnotations(pageNum);
2940
+ eraseAt(svg, x, y, scaleX, coords.clientX, coords.clientY);
2941
+ // Performance: Don't save here - isDrawing is true, stopDraw will save on mouseup
2827
2942
  return;
2828
2943
  }
2829
2944
 
@@ -2923,9 +3038,8 @@
2923
3038
  const scaleX = vb.scaleX;
2924
3039
 
2925
3040
  if (currentTool === 'eraser') {
2926
- eraseAt(svg, x, y, scaleX);
2927
- // Bug fix: Save after continuous erasing so changes aren't lost
2928
- if (currentDrawingPage) saveAnnotations(currentDrawingPage);
3041
+ eraseAt(svg, x, y, scaleX, coords.clientX, coords.clientY);
3042
+ // Performance: Don't save on every mousemove - let mouseup handle it
2929
3043
  return;
2930
3044
  }
2931
3045
 
@@ -2958,12 +3072,33 @@
2958
3072
  return;
2959
3073
  }
2960
3074
 
3075
+ // RAF throttle: buffer segments and flush in animation frame
2961
3076
  if (currentPath) {
2962
- currentPath.setAttribute('d', currentPath.getAttribute('d') + ` L${x.toFixed(2)},${y.toFixed(2)}`);
3077
+ pathSegments.push(`L${x.toFixed(2)},${y.toFixed(2)}`);
3078
+
3079
+ if (!drawRAF) {
3080
+ drawRAF = requestAnimationFrame(() => {
3081
+ if (currentPath && pathSegments.length > 0) {
3082
+ currentPath.setAttribute('d', currentPath.getAttribute('d') + ' ' + pathSegments.join(' '));
3083
+ pathSegments = [];
3084
+ }
3085
+ drawRAF = null;
3086
+ });
3087
+ }
2963
3088
  }
2964
3089
  }
2965
3090
 
2966
3091
  function stopDraw(pageNum) {
3092
+ // Flush any pending path segments before stopping
3093
+ if (drawRAF) {
3094
+ cancelAnimationFrame(drawRAF);
3095
+ drawRAF = null;
3096
+ }
3097
+ if (currentPath && pathSegments.length > 0) {
3098
+ currentPath.setAttribute('d', currentPath.getAttribute('d') + ' ' + pathSegments.join(' '));
3099
+ pathSegments = [];
3100
+ }
3101
+
2967
3102
  // Handle arrow marker
2968
3103
  if (currentTool === 'shape' && currentShape === 'arrow' && currentSvg) {
2969
3104
  const shapeEl = currentSvg.querySelector('.current-shape');
@@ -3311,13 +3446,12 @@
3311
3446
  });
3312
3447
  }
3313
3448
 
3314
- function eraseAt(svg, x, y, scale = 1) {
3315
- const hitRadius = 15 * scale; // Scale hit radius with viewBox
3316
- // Erase paths, text, and shape elements (rect, ellipse, line)
3317
- svg.querySelectorAll('path, text, rect, ellipse, line').forEach(el => {
3318
- const bbox = el.getBBox();
3319
- if (x >= bbox.x - hitRadius && x <= bbox.x + bbox.width + hitRadius &&
3320
- y >= bbox.y - hitRadius && y <= bbox.y + bbox.height + hitRadius) {
3449
+ function eraseAt(svg, x, y, scale = 1, clientX, clientY) {
3450
+ // Use elementsFromPoint for rotation-aware hit testing
3451
+ const annotationTags = new Set(['path', 'text', 'rect', 'ellipse', 'line']);
3452
+ const elements = document.elementsFromPoint(clientX, clientY);
3453
+ elements.forEach(el => {
3454
+ if (el.closest('.annotationLayer') === svg && annotationTags.has(el.tagName)) {
3321
3455
  el.remove();
3322
3456
  }
3323
3457
  });
@@ -4243,12 +4377,16 @@
4243
4377
  // ERGONOMIC FEATURES
4244
4378
  // ==========================================
4245
4379
 
4246
- // Fullscreen toggle function
4380
+ // Fullscreen toggle function (with webkit fallback for iOS Safari)
4247
4381
  function toggleFullscreen() {
4248
- if (document.fullscreenElement) {
4249
- document.exitFullscreen();
4382
+ const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
4383
+ if (fsEl) {
4384
+ if (document.exitFullscreen) document.exitFullscreen();
4385
+ else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
4250
4386
  } else {
4251
- document.documentElement.requestFullscreen().catch(() => { });
4387
+ const el = document.documentElement;
4388
+ if (el.requestFullscreen) el.requestFullscreen().catch(() => {});
4389
+ else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen();
4252
4390
  }
4253
4391
  }
4254
4392
 
@@ -4256,7 +4394,8 @@
4256
4394
  function updateFullscreenIcon() {
4257
4395
  const icon = document.getElementById('fullscreenIcon');
4258
4396
  const btn = document.getElementById('fullscreenBtn');
4259
- if (document.fullscreenElement) {
4397
+ const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
4398
+ if (fsEl) {
4260
4399
  icon.innerHTML = '<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>';
4261
4400
  btn.classList.add('active');
4262
4401
  } else {
@@ -4266,31 +4405,12 @@
4266
4405
  }
4267
4406
 
4268
4407
  document.addEventListener('fullscreenchange', updateFullscreenIcon);
4408
+ document.addEventListener('webkitfullscreenchange', updateFullscreenIcon);
4269
4409
 
4270
4410
  // Fullscreen button click
4271
4411
  document.getElementById('fullscreenBtn').onclick = () => toggleFullscreen();
4272
4412
 
4273
- // Double-click on page for fullscreen
4274
- let lastClickTime = 0;
4275
- container.addEventListener('click', (e) => {
4276
- const now = Date.now();
4277
- if (now - lastClickTime < 300) {
4278
- toggleFullscreen();
4279
- }
4280
- lastClickTime = now;
4281
- });
4282
4413
 
4283
- // Auto-fullscreen when viewer loads inside iframe
4284
- if (window.self !== window.top && window.PDF_SECURE_CONFIG) {
4285
- // We're inside an iframe - request fullscreen on first user interaction
4286
- const autoFullscreen = () => {
4287
- document.documentElement.requestFullscreen().catch(() => { });
4288
- container.removeEventListener('click', autoFullscreen);
4289
- container.removeEventListener('touchstart', autoFullscreen);
4290
- };
4291
- container.addEventListener('click', autoFullscreen, { once: true });
4292
- container.addEventListener('touchstart', autoFullscreen, { once: true });
4293
- }
4294
4414
 
4295
4415
  // Mouse wheel zoom with Ctrl
4296
4416
  container.addEventListener('wheel', (e) => {
@@ -4374,9 +4494,11 @@
4374
4494
  setupResponsiveToolbar();
4375
4495
  });
4376
4496
 
4377
- // Also handle resize for orientation changes
4497
+ // Also handle resize for orientation changes (debounced)
4498
+ let resizeTimer;
4378
4499
  window.addEventListener('resize', () => {
4379
- setupResponsiveToolbar();
4500
+ clearTimeout(resizeTimer);
4501
+ resizeTimer = setTimeout(setupResponsiveToolbar, 150);
4380
4502
  });
4381
4503
 
4382
4504
  // ==========================================
@@ -4393,7 +4515,18 @@
4393
4515
  }
4394
4516
 
4395
4517
  container.addEventListener('touchstart', (e) => {
4396
- if (e.touches.length === 2 && !currentTool) {
4518
+ if (e.touches.length === 2) {
4519
+ // Cancel any active drawing and clean up
4520
+ if (isDrawing && currentDrawingPage) {
4521
+ // Remove incomplete path
4522
+ if (currentPath && currentPath.parentNode) currentPath.remove();
4523
+ currentPath = null;
4524
+ pathSegments = [];
4525
+ if (drawRAF) { cancelAnimationFrame(drawRAF); drawRAF = null; }
4526
+ isDrawing = false;
4527
+ currentSvg = null;
4528
+ currentDrawingPage = null;
4529
+ }
4397
4530
  isPinching = true;
4398
4531
  pinchStartDistance = getTouchDistance(e.touches);
4399
4532
  pinchStartScale = pdfViewer.currentScale;