nodebb-plugin-pdf-secure 1.2.12 → 1.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-pdf-secure",
3
- "version": "1.2.12",
3
+ "version": "1.2.14",
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": {
@@ -73,11 +73,13 @@
73
73
  const cached = pdfBufferCache.get(filename);
74
74
  if (cached && event.source) {
75
75
  // Send cached buffer to viewer (transferable for 0-copy)
76
+ // Clone once: keep original in cache, transfer the copy
77
+ const copy = cached.slice(0);
76
78
  event.source.postMessage({
77
79
  type: 'pdf-secure-cache-response',
78
80
  filename: filename,
79
- buffer: cached
80
- }, event.origin, [cached.slice(0)]); // Clone buffer since we keep original
81
+ buffer: copy
82
+ }, event.origin, [copy]);
81
83
  console.log('[PDF-Secure] Cache: Hit -', filename);
82
84
  } else if (event.source) {
83
85
  // No cache, viewer will fetch normally
@@ -52,6 +52,8 @@
52
52
  -moz-user-select: none;
53
53
  -ms-user-select: none;
54
54
  user-select: none;
55
+ /* Prevent scroll chaining and browser overscroll gestures (e.g. pull-to-exit-fullscreen) */
56
+ overscroll-behavior: none;
55
57
  }
56
58
 
57
59
  /* Print Protection - hide everything when printing */
@@ -624,6 +626,9 @@
624
626
  overflow: auto;
625
627
  background: #525659;
626
628
  z-index: 1;
629
+ /* Prevent scroll chaining and overscroll gestures on touch devices */
630
+ overscroll-behavior: none;
631
+ -webkit-overflow-scrolling: touch;
627
632
  }
628
633
 
629
634
  #viewerContainer.withSidebar {
@@ -1524,6 +1529,11 @@
1524
1529
  width: 56px;
1525
1530
  height: 56px;
1526
1531
  }
1532
+
1533
+ /* Let our JS handle pinch-to-zoom, but allow browser pan-y for scroll */
1534
+ #viewerContainer {
1535
+ touch-action: manipulation;
1536
+ }
1527
1537
  }
1528
1538
  </style>
1529
1539
  </head>
@@ -2243,8 +2253,10 @@
2243
2253
  }
2244
2254
 
2245
2255
  // Events
2256
+ let initialScale = 1;
2246
2257
  eventBus.on('pagesinit', () => {
2247
2258
  pdfViewer.currentScaleValue = 'page-width';
2259
+ initialScale = pdfViewer.currentScale;
2248
2260
  document.getElementById('pageCount').textContent = `/ ${pdfViewer.pagesCount}`;
2249
2261
  });
2250
2262
 
@@ -2688,17 +2700,89 @@
2688
2700
  svg.addEventListener('mouseleave', () => stopDraw(pageNum), { signal });
2689
2701
 
2690
2702
  // Touch support for tablets
2703
+ // Uses direction detection: predominantly vertical = scroll, otherwise = draw
2704
+ let touchDrawDecided = false;
2705
+ let touchDrawing = false;
2706
+ let touchStartX = 0;
2707
+ let touchStartYDraw = 0;
2708
+
2691
2709
  svg.addEventListener('touchstart', (e) => {
2692
- // Prevent default to avoid scroll while drawing/selecting
2693
- if (currentTool) e.preventDefault();
2694
- startDraw(e, pageNum);
2710
+ if (!currentTool || e.touches.length !== 1) return;
2711
+
2712
+ // Eraser and select need immediate response
2713
+ if (currentTool === 'eraser' || currentTool === 'select') {
2714
+ e.preventDefault();
2715
+ startDraw(e, pageNum);
2716
+ touchDrawing = true;
2717
+ touchDrawDecided = true;
2718
+ return;
2719
+ }
2720
+
2721
+ // For pen/highlight/shape: wait to decide scroll vs draw
2722
+ touchDrawDecided = false;
2723
+ touchDrawing = false;
2724
+ touchStartX = e.touches[0].clientX;
2725
+ touchStartYDraw = e.touches[0].clientY;
2695
2726
  }, { passive: false, signal });
2727
+
2696
2728
  svg.addEventListener('touchmove', (e) => {
2697
- if (currentTool) e.preventDefault();
2698
- draw(e);
2729
+ if (!currentTool || e.touches.length !== 1) return;
2730
+
2731
+ if (touchDrawing) {
2732
+ // Already decided: drawing
2733
+ e.preventDefault();
2734
+ draw(e);
2735
+ return;
2736
+ }
2737
+
2738
+ if (touchDrawDecided) return; // decided scroll, let it through
2739
+
2740
+ const dx = e.touches[0].clientX - touchStartX;
2741
+ const dy = e.touches[0].clientY - touchStartYDraw;
2742
+
2743
+ if (Math.abs(dx) + Math.abs(dy) > 10) {
2744
+ touchDrawDecided = true;
2745
+
2746
+ if (Math.abs(dy) > Math.abs(dx) * 2) {
2747
+ // Predominantly vertical → scroll (don't prevent default)
2748
+ return;
2749
+ }
2750
+ // Horizontal or diagonal → drawing
2751
+ e.preventDefault();
2752
+ // Start draw from ORIGINAL touch position (not current 10px-offset position)
2753
+ const syntheticStart = {
2754
+ currentTarget: svg,
2755
+ touches: [{ clientX: touchStartX, clientY: touchStartYDraw }],
2756
+ preventDefault: () => {}
2757
+ };
2758
+ startDraw(syntheticStart, pageNum);
2759
+ // Then immediately draw to current position for continuity
2760
+ draw(e);
2761
+ touchDrawing = true;
2762
+ }
2699
2763
  }, { passive: false, signal });
2700
- svg.addEventListener('touchend', () => stopDraw(pageNum), { signal });
2701
- svg.addEventListener('touchcancel', () => stopDraw(pageNum), { signal });
2764
+
2765
+ svg.addEventListener('touchend', (e) => {
2766
+ if (!touchDrawDecided && currentTool && currentTool !== 'eraser' && currentTool !== 'select') {
2767
+ // Tap without moving > 10px → draw a dot at touch position
2768
+ const syntheticStart = {
2769
+ currentTarget: svg,
2770
+ touches: [{ clientX: touchStartX, clientY: touchStartYDraw }],
2771
+ preventDefault: () => {}
2772
+ };
2773
+ startDraw(syntheticStart, pageNum);
2774
+ stopDraw(pageNum);
2775
+ } else if (touchDrawing) {
2776
+ stopDraw(pageNum);
2777
+ }
2778
+ touchDrawDecided = false;
2779
+ touchDrawing = false;
2780
+ }, { signal });
2781
+ svg.addEventListener('touchcancel', () => {
2782
+ if (touchDrawing) stopDraw(pageNum);
2783
+ touchDrawDecided = false;
2784
+ touchDrawing = false;
2785
+ }, { signal });
2702
2786
 
2703
2787
  svg.classList.toggle('active', annotationMode);
2704
2788
  }
@@ -2894,7 +2978,7 @@
2894
2978
  // Save current state to undo stack with rotation
2895
2979
  if (!undoStacks.has(pageNum)) undoStacks.set(pageNum, []);
2896
2980
  const stack = undoStacks.get(pageNum);
2897
- stack.push({ html: svg.innerHTML, rotation: pdfViewer.pagesRotation || 0 });
2981
+ stack.push({ html: getCleanSvgInnerHTML(svg), rotation: pdfViewer.pagesRotation || 0 });
2898
2982
  if (stack.length > MAX_HISTORY) stack.shift();
2899
2983
 
2900
2984
  // Clear redo stack
@@ -4385,7 +4469,7 @@
4385
4469
  else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
4386
4470
  } else {
4387
4471
  const el = document.documentElement;
4388
- if (el.requestFullscreen) el.requestFullscreen().catch(() => {});
4472
+ if (el.requestFullscreen) el.requestFullscreen().catch(() => { });
4389
4473
  else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen();
4390
4474
  }
4391
4475
  }
@@ -4502,11 +4586,17 @@
4502
4586
  });
4503
4587
 
4504
4588
  // ==========================================
4505
- // PINCH-TO-ZOOM (Touch devices)
4589
+ // PINCH-TO-ZOOM (Touch devices) - Smooth CSS Transform
4506
4590
  // ==========================================
4507
4591
  let pinchStartDistance = 0;
4508
4592
  let pinchStartScale = 1;
4509
4593
  let isPinching = false;
4594
+ let pinchMidX = 0;
4595
+ let pinchMidY = 0;
4596
+ let pinchVisualRatio = 1;
4597
+
4598
+ // The viewer div that holds PDF pages
4599
+ const viewerDiv = document.getElementById('viewer');
4510
4600
 
4511
4601
  function getTouchDistance(touches) {
4512
4602
  const dx = touches[0].clientX - touches[1].clientX;
@@ -4514,11 +4604,18 @@
4514
4604
  return Math.sqrt(dx * dx + dy * dy);
4515
4605
  }
4516
4606
 
4607
+ function getTouchMidpoint(touches) {
4608
+ return {
4609
+ x: (touches[0].clientX + touches[1].clientX) / 2,
4610
+ y: (touches[0].clientY + touches[1].clientY) / 2
4611
+ };
4612
+ }
4613
+
4517
4614
  container.addEventListener('touchstart', (e) => {
4518
4615
  if (e.touches.length === 2) {
4519
4616
  // Cancel any active drawing and clean up
4520
4617
  if (isDrawing && currentDrawingPage) {
4521
- // Remove incomplete path
4618
+ const savePage = currentDrawingPage;
4522
4619
  if (currentPath && currentPath.parentNode) currentPath.remove();
4523
4620
  currentPath = null;
4524
4621
  pathSegments = [];
@@ -4526,10 +4623,23 @@
4526
4623
  isDrawing = false;
4527
4624
  currentSvg = null;
4528
4625
  currentDrawingPage = null;
4626
+ saveAnnotations(savePage);
4529
4627
  }
4530
4628
  isPinching = true;
4531
4629
  pinchStartDistance = getTouchDistance(e.touches);
4532
4630
  pinchStartScale = pdfViewer.currentScale;
4631
+ pinchVisualRatio = 1;
4632
+
4633
+ // Get midpoint relative to container
4634
+ const mid = getTouchMidpoint(e.touches);
4635
+ const rect = container.getBoundingClientRect();
4636
+ pinchMidX = mid.x - rect.left + container.scrollLeft;
4637
+ pinchMidY = mid.y - rect.top + container.scrollTop;
4638
+
4639
+ // Prepare for CSS transform - use will-change for GPU acceleration
4640
+ viewerDiv.style.willChange = 'transform';
4641
+ viewerDiv.style.transformOrigin = pinchMidX + 'px ' + pinchMidY + 'px';
4642
+
4533
4643
  e.preventDefault();
4534
4644
  }
4535
4645
  }, { passive: false });
@@ -4538,18 +4648,177 @@
4538
4648
  if (isPinching && e.touches.length === 2) {
4539
4649
  const dist = getTouchDistance(e.touches);
4540
4650
  const ratio = dist / pinchStartDistance;
4541
- const newScale = Math.min(Math.max(pinchStartScale * ratio, 0.5), 5.0);
4542
- pdfViewer.currentScale = newScale;
4651
+ // Clamp the target scale
4652
+ const targetScale = pinchStartScale * ratio;
4653
+ const clampedScale = Math.min(Math.max(targetScale, 0.5), 5.0);
4654
+ pinchVisualRatio = clampedScale / pinchStartScale;
4655
+
4656
+ // Apply CSS transform for instant smooth visual feedback (no re-render)
4657
+ viewerDiv.style.transform = 'scale(' + pinchVisualRatio + ')';
4658
+
4543
4659
  e.preventDefault();
4544
4660
  }
4545
4661
  }, { passive: false });
4546
4662
 
4663
+ let pinchJustEnded = false;
4664
+
4547
4665
  container.addEventListener('touchend', (e) => {
4548
- if (e.touches.length < 2) {
4666
+ if (isPinching && e.touches.length < 2) {
4549
4667
  isPinching = false;
4668
+
4669
+ // Prevent double-tap false positive right after pinch ends
4670
+ pinchJustEnded = true;
4671
+ setTimeout(() => { pinchJustEnded = false; }, 400);
4672
+
4673
+ // Remove CSS transform
4674
+ viewerDiv.style.transform = '';
4675
+ viewerDiv.style.willChange = '';
4676
+ viewerDiv.style.transformOrigin = '';
4677
+
4678
+ // Apply actual scale with scroll position preservation
4679
+ const finalScale = Math.min(Math.max(pinchStartScale * pinchVisualRatio, 0.5), 5.0);
4680
+ if (Math.abs(finalScale - pdfViewer.currentScale) > 0.01) {
4681
+ const scrollRatio = finalScale / pdfViewer.currentScale;
4682
+ const midRect = container.getBoundingClientRect();
4683
+ const viewCenterX = midRect.width / 2;
4684
+ const viewCenterY = midRect.height / 2;
4685
+ const oldScrollLeft = container.scrollLeft;
4686
+ const oldScrollTop = container.scrollTop;
4687
+
4688
+ pdfViewer.currentScale = finalScale;
4689
+
4690
+ // Adjust scroll after PDF.js re-render to keep view centered
4691
+ requestAnimationFrame(() => {
4692
+ container.scrollLeft = (oldScrollLeft + viewCenterX) * scrollRatio - viewCenterX;
4693
+ container.scrollTop = (oldScrollTop + viewCenterY) * scrollRatio - viewCenterY;
4694
+ });
4695
+ }
4696
+ pinchVisualRatio = 1;
4550
4697
  }
4551
4698
  });
4552
4699
 
4700
+ container.addEventListener('touchcancel', (e) => {
4701
+ if (isPinching) {
4702
+ isPinching = false;
4703
+ viewerDiv.style.transform = '';
4704
+ viewerDiv.style.willChange = '';
4705
+ viewerDiv.style.transformOrigin = '';
4706
+ pinchVisualRatio = 1;
4707
+ }
4708
+ });
4709
+
4710
+ // ==========================================
4711
+ // DOUBLE-TAP TO ZOOM (Tablet)
4712
+ // ==========================================
4713
+ let lastTapTime = 0;
4714
+ let lastTapX = 0;
4715
+ let lastTapY = 0;
4716
+
4717
+ container.addEventListener('touchend', (e) => {
4718
+ // Skip during pinch, multi-touch, annotation mode, or right after pinch ends
4719
+ if (isPinching || e.touches.length !== 0 || pinchJustEnded) return;
4720
+ if (annotationMode && currentTool) return;
4721
+
4722
+ const now = Date.now();
4723
+ const touch = e.changedTouches[0];
4724
+ const dx = touch.clientX - lastTapX;
4725
+ const dy = touch.clientY - lastTapY;
4726
+ const dist = Math.sqrt(dx * dx + dy * dy);
4727
+
4728
+ if (now - lastTapTime < 300 && dist < 30) {
4729
+ // Double-tap detected
4730
+ e.preventDefault();
4731
+
4732
+ if (pdfViewer.currentScaleValue === 'page-width' ||
4733
+ Math.abs(pdfViewer.currentScale - initialScale) < 0.01) {
4734
+ // Zoom in 2x, centered on tap point
4735
+ const rect = container.getBoundingClientRect();
4736
+ const tapX = touch.clientX - rect.left;
4737
+ const tapY = touch.clientY - rect.top;
4738
+
4739
+ pdfViewer.currentScale = initialScale * 2;
4740
+
4741
+ // Scroll to keep tap point centered
4742
+ container.scrollLeft = (tapX + container.scrollLeft) * 2 - tapX;
4743
+ container.scrollTop = (tapY + container.scrollTop) * 2 - tapY;
4744
+ } else {
4745
+ // Reset to page-width
4746
+ pdfViewer.currentScaleValue = 'page-width';
4747
+ }
4748
+
4749
+ lastTapTime = 0;
4750
+ return;
4751
+ }
4752
+
4753
+ lastTapTime = now;
4754
+ lastTapX = touch.clientX;
4755
+ lastTapY = touch.clientY;
4756
+ });
4757
+
4758
+ // ==========================================
4759
+ // TOUCH SCROLL BOUNDARY (Prevent exit on tablet)
4760
+ // ==========================================
4761
+ // On touch devices, prevent scroll from escaping the container
4762
+ // when at the top/bottom boundary. Only fullscreen button should exit.
4763
+ (function initTouchScrollBoundary() {
4764
+ if (!isTouch()) return;
4765
+
4766
+ // Ensure html element also blocks overscroll
4767
+ document.documentElement.style.overscrollBehavior = 'none';
4768
+
4769
+ let touchStartY = 0;
4770
+
4771
+ container.addEventListener('touchstart', (e) => {
4772
+ if (e.touches.length === 1) {
4773
+ touchStartY = e.touches[0].clientY;
4774
+ }
4775
+ }, { passive: true });
4776
+
4777
+ // Container-level boundary prevention
4778
+ container.addEventListener('touchmove', (e) => {
4779
+ if (isPinching || e.touches.length !== 1) return;
4780
+
4781
+ const touchY = e.touches[0].clientY;
4782
+ const deltaY = touchStartY - touchY; // positive = scrolling down
4783
+
4784
+ const atTop = container.scrollTop <= 0;
4785
+ const atBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 1;
4786
+
4787
+ if ((atTop && deltaY < 0) || (atBottom && deltaY > 0)) {
4788
+ e.preventDefault();
4789
+ }
4790
+ touchStartY = touchY;
4791
+ }, { passive: false });
4792
+
4793
+ // Document-level capture handler: catch overscroll gestures
4794
+ // that escape the container (e.g. browser pull-to-exit-fullscreen)
4795
+ let docTouchStartY = 0;
4796
+ document.addEventListener('touchstart', (e) => {
4797
+ if (e.touches.length === 1) {
4798
+ docTouchStartY = e.touches[0].clientY;
4799
+ }
4800
+ }, { passive: true, capture: true });
4801
+
4802
+ document.addEventListener('touchmove', (e) => {
4803
+ if (e.touches.length !== 1) return;
4804
+
4805
+ // Only prevent when in fullscreen
4806
+ const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
4807
+ if (!fsEl) return;
4808
+
4809
+ const touchY = e.touches[0].clientY;
4810
+ const deltaY = docTouchStartY - touchY;
4811
+ const atTop = container.scrollTop <= 0;
4812
+ const atBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 1;
4813
+
4814
+ // Block pull-down at top and pull-up at bottom in fullscreen
4815
+ if ((atTop && deltaY < 0) || (atBottom && deltaY > 0)) {
4816
+ e.preventDefault();
4817
+ }
4818
+ docTouchStartY = touchY;
4819
+ }, { passive: false, capture: true });
4820
+ })();
4821
+
4553
4822
  // ==========================================
4554
4823
  // CONTEXT MENU TOUCH HANDLING
4555
4824
  // ==========================================