nodebb-plugin-pdf-secure 1.2.10 → 1.2.11

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/library.js CHANGED
@@ -36,8 +36,19 @@ 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();
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.11",
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 {
@@ -1785,23 +1809,32 @@
1785
1809
  <div class="toolbarBtnWithDropdown" id="overflowWrapper">
1786
1810
  <button class="toolbarBtn" id="overflowBtn" title="Daha Fazla">
1787
1811
  <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"/>
1812
+ <circle cx="12" cy="5" r="2" />
1813
+ <circle cx="12" cy="12" r="2" />
1814
+ <circle cx="12" cy="19" r="2" />
1791
1815
  </svg>
1792
1816
  </button>
1793
1817
  <div class="toolDropdown" id="overflowDropdown">
1794
1818
  <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>
1819
+ <svg viewBox="0 0 24 24">
1820
+ <path
1821
+ 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" />
1822
+ </svg>
1796
1823
  <span>Sola Döndür</span>
1797
1824
  </button>
1798
1825
  <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>
1826
+ <svg viewBox="0 0 24 24">
1827
+ <path
1828
+ 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" />
1829
+ </svg>
1800
1830
  <span>Sağa Döndür</span>
1801
1831
  </button>
1802
1832
  <div class="overflowDivider"></div>
1803
1833
  <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>
1834
+ <svg viewBox="0 0 24 24">
1835
+ <path
1836
+ 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" />
1837
+ </svg>
1805
1838
  <span>Okuma Modu</span>
1806
1839
  </button>
1807
1840
  </div>
@@ -1896,6 +1929,10 @@
1896
1929
  let currentPath = null;
1897
1930
  let currentDrawingPage = null;
1898
1931
 
1932
+ // RAF throttle for smooth drawing performance
1933
+ let pathSegments = []; // Buffer path segments
1934
+ let drawRAF = null; // requestAnimationFrame ID
1935
+
1899
1936
  // Annotation persistence - stores SVG innerHTML per page
1900
1937
  const annotationsStore = new Map();
1901
1938
  const annotationRotations = new Map(); // tracks rotation when annotations were saved
@@ -2406,34 +2443,11 @@
2406
2443
  currentWidth = shapeWidth;
2407
2444
  }
2408
2445
 
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);
2446
+ // Performance: Just toggle active class instead of re-injecting layers
2447
+ // This avoids expensive DOM recreation on every tool change
2448
+ document.querySelectorAll('.annotationLayer').forEach(layer => {
2449
+ layer.classList.toggle('active', annotationMode);
2450
+ });
2437
2451
  }
2438
2452
 
2439
2453
  // Update button states
@@ -2602,18 +2616,8 @@
2602
2616
  annotationsStore.set(pageNum, svg.innerHTML);
2603
2617
  annotationRotations.set(pageNum, curRot);
2604
2618
 
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);
2619
+ // Note: No need to wrap stack entries anymore
2620
+ // Rotation is now stored per-entry, transforms applied on restore
2617
2621
  }
2618
2622
  }
2619
2623
 
@@ -2645,13 +2649,16 @@
2645
2649
 
2646
2650
  // Strip transient classes, styles, and elements from SVG before saving
2647
2651
  function getCleanSvgInnerHTML(svg) {
2652
+ // Performance: Work on a cloned node to avoid modifying live DOM
2653
+ const clone = svg.cloneNode(true);
2654
+
2648
2655
  // Remove marquee rect if present
2649
- const marquee = svg.querySelector('.marquee-rect');
2656
+ const marquee = clone.querySelector('.marquee-rect');
2650
2657
  if (marquee) marquee.remove();
2651
2658
 
2652
2659
  // Strip transient classes and inline styles from annotation elements
2653
2660
  const transientClasses = ['annotation-selected', 'annotation-multi-selected', 'annotation-dragging', 'just-selected'];
2654
- svg.querySelectorAll('path, rect, ellipse, line, text').forEach(el => {
2661
+ clone.querySelectorAll('path, rect, ellipse, line, text').forEach(el => {
2655
2662
  transientClasses.forEach(cls => el.classList.remove(cls));
2656
2663
  // Remove inline cursor style added by multi-drag
2657
2664
  if (el.style.cursor) el.style.cursor = '';
@@ -2661,7 +2668,50 @@
2661
2668
  if (el.getAttribute('class') === '') el.removeAttribute('class');
2662
2669
  });
2663
2670
 
2664
- return svg.innerHTML.trim();
2671
+ return clone.innerHTML.trim();
2672
+ }
2673
+
2674
+ // Helper: Apply rotation transform from savedRot to curRot
2675
+ // Uses clone-based flatten approach - updates each element's transform individually
2676
+ // This prevents nested <g> accumulation entirely
2677
+ function applyRotationTransform(html, savedRot, curRot, pageNum) {
2678
+ if (!html || !html.trim()) return html;
2679
+
2680
+ const delta = (curRot - savedRot + 360) % 360;
2681
+ if (delta === 0) return html;
2682
+
2683
+ // Calculate transform based on page dimensions
2684
+ const baseDims = pageBaseDimensions.get(pageNum);
2685
+ if (!baseDims) return html; // Fallback if no dims available
2686
+
2687
+ const W = baseDims.width, H = baseDims.height;
2688
+
2689
+ // Old viewBox dimensions (at saved rotation)
2690
+ let oW, oH;
2691
+ if (savedRot === 90 || savedRot === 270) { oW = H; oH = W; }
2692
+ else { oW = W; oH = H; }
2693
+
2694
+ let rotationTransform;
2695
+ if (delta === 90) rotationTransform = `translate(${oH},0) rotate(90)`;
2696
+ else if (delta === 180) rotationTransform = `translate(${oW},${oH}) rotate(180)`;
2697
+ else if (delta === 270) rotationTransform = `translate(0,${oW}) rotate(270)`;
2698
+ else return html;
2699
+
2700
+ // Clone-based flatten: Apply transform to each top-level element individually
2701
+ const tempContainer = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
2702
+ tempContainer.innerHTML = html;
2703
+
2704
+ // Process each top-level child element
2705
+ Array.from(tempContainer.children).forEach(child => {
2706
+ const existingTransform = child.getAttribute('transform') || '';
2707
+ // Prepend rotation transform (rotation first, then existing)
2708
+ const newTransform = existingTransform
2709
+ ? `${rotationTransform} ${existingTransform}`
2710
+ : rotationTransform;
2711
+ child.setAttribute('transform', newTransform);
2712
+ });
2713
+
2714
+ return tempContainer.innerHTML;
2665
2715
  }
2666
2716
 
2667
2717
  // Save annotations for a page (with undo history)
@@ -2678,7 +2728,8 @@
2678
2728
  if (previousState !== newState) {
2679
2729
  if (!undoStacks.has(pageNum)) undoStacks.set(pageNum, []);
2680
2730
  const stack = undoStacks.get(pageNum);
2681
- stack.push(previousState);
2731
+ // Store as {html, rotation} object to avoid nested <g> wrap accumulation
2732
+ stack.push({ html: previousState, rotation: annotationRotations.get(pageNum) || 0 });
2682
2733
  if (stack.length > MAX_HISTORY) stack.shift();
2683
2734
 
2684
2735
  // Clear redo stack on new action
@@ -2715,20 +2766,24 @@
2715
2766
  const svg = pageView?.div?.querySelector('.annotationLayer');
2716
2767
  if (!svg) return;
2717
2768
 
2718
- // Save current state to redo stack (clean)
2769
+ // Save current state to redo stack with rotation
2719
2770
  if (!redoStacks.has(pageNum)) redoStacks.set(pageNum, []);
2720
2771
  const redoStack = redoStacks.get(pageNum);
2721
- redoStack.push(getCleanSvgInnerHTML(svg));
2772
+ redoStack.push({ html: getCleanSvgInnerHTML(svg), rotation: pdfViewer.pagesRotation || 0 });
2722
2773
  if (redoStack.length > MAX_HISTORY) redoStack.shift();
2723
2774
 
2724
- // Restore previous state
2725
- const previousState = stack.pop();
2726
- svg.innerHTML = previousState;
2775
+ // Restore previous state with rotation transform if needed
2776
+ const entry = stack.pop();
2777
+ const previousHtml = typeof entry === 'object' ? entry.html : entry;
2778
+ const savedRot = typeof entry === 'object' ? entry.rotation : (annotationRotations.get(pageNum) || 0);
2779
+ const curRot = pdfViewer.pagesRotation || 0;
2780
+
2781
+ svg.innerHTML = applyRotationTransform(previousHtml, savedRot, curRot, pageNum);
2727
2782
 
2728
2783
  // Update store
2729
- if (previousState.trim()) {
2730
- annotationsStore.set(pageNum, previousState);
2731
- annotationRotations.set(pageNum, pdfViewer.pagesRotation || 0);
2784
+ if (previousHtml.trim()) {
2785
+ annotationsStore.set(pageNum, svg.innerHTML);
2786
+ annotationRotations.set(pageNum, curRot);
2732
2787
  } else {
2733
2788
  annotationsStore.delete(pageNum);
2734
2789
  annotationRotations.delete(pageNum);
@@ -2747,20 +2802,24 @@
2747
2802
  const svg = pageView?.div?.querySelector('.annotationLayer');
2748
2803
  if (!svg) return;
2749
2804
 
2750
- // Save current state to undo stack (clean)
2805
+ // Save current state to undo stack with rotation
2751
2806
  if (!undoStacks.has(pageNum)) undoStacks.set(pageNum, []);
2752
2807
  const undoStack = undoStacks.get(pageNum);
2753
- undoStack.push(getCleanSvgInnerHTML(svg));
2808
+ undoStack.push({ html: getCleanSvgInnerHTML(svg), rotation: pdfViewer.pagesRotation || 0 });
2754
2809
  if (undoStack.length > MAX_HISTORY) undoStack.shift();
2755
2810
 
2756
- // Restore redo state
2757
- const redoState = stack.pop();
2758
- svg.innerHTML = redoState;
2811
+ // Restore redo state with rotation transform if needed
2812
+ const entry = stack.pop();
2813
+ const redoHtml = typeof entry === 'object' ? entry.html : entry;
2814
+ const savedRot = typeof entry === 'object' ? entry.rotation : (annotationRotations.get(pageNum) || 0);
2815
+ const curRot = pdfViewer.pagesRotation || 0;
2816
+
2817
+ svg.innerHTML = applyRotationTransform(redoHtml, savedRot, curRot, pageNum);
2759
2818
 
2760
2819
  // Update store
2761
- if (redoState.trim()) {
2762
- annotationsStore.set(pageNum, redoState);
2763
- annotationRotations.set(pageNum, pdfViewer.pagesRotation || 0);
2820
+ if (redoHtml.trim()) {
2821
+ annotationsStore.set(pageNum, svg.innerHTML);
2822
+ annotationRotations.set(pageNum, curRot);
2764
2823
  } else {
2765
2824
  annotationsStore.delete(pageNum);
2766
2825
  annotationRotations.delete(pageNum);
@@ -2776,10 +2835,10 @@
2776
2835
  const svg = pageView?.div?.querySelector('.annotationLayer');
2777
2836
  if (!svg || !svg.innerHTML.trim()) return;
2778
2837
 
2779
- // Save current state to undo stack (so it can be undone)
2838
+ // Save current state to undo stack with rotation
2780
2839
  if (!undoStacks.has(pageNum)) undoStacks.set(pageNum, []);
2781
2840
  const stack = undoStacks.get(pageNum);
2782
- stack.push(svg.innerHTML);
2841
+ stack.push({ html: svg.innerHTML, rotation: pdfViewer.pagesRotation || 0 });
2783
2842
  if (stack.length > MAX_HISTORY) stack.shift();
2784
2843
 
2785
2844
  // Clear redo stack
@@ -2823,7 +2882,7 @@
2823
2882
 
2824
2883
  if (currentTool === 'eraser') {
2825
2884
  eraseAt(svg, x, y, scaleX);
2826
- saveAnnotations(pageNum);
2885
+ // Performance: Don't save here - isDrawing is true, stopDraw will save on mouseup
2827
2886
  return;
2828
2887
  }
2829
2888
 
@@ -2924,8 +2983,7 @@
2924
2983
 
2925
2984
  if (currentTool === 'eraser') {
2926
2985
  eraseAt(svg, x, y, scaleX);
2927
- // Bug fix: Save after continuous erasing so changes aren't lost
2928
- if (currentDrawingPage) saveAnnotations(currentDrawingPage);
2986
+ // Performance: Don't save on every mousemove - let mouseup handle it
2929
2987
  return;
2930
2988
  }
2931
2989
 
@@ -2958,12 +3016,33 @@
2958
3016
  return;
2959
3017
  }
2960
3018
 
3019
+ // RAF throttle: buffer segments and flush in animation frame
2961
3020
  if (currentPath) {
2962
- currentPath.setAttribute('d', currentPath.getAttribute('d') + ` L${x.toFixed(2)},${y.toFixed(2)}`);
3021
+ pathSegments.push(`L${x.toFixed(2)},${y.toFixed(2)}`);
3022
+
3023
+ if (!drawRAF) {
3024
+ drawRAF = requestAnimationFrame(() => {
3025
+ if (currentPath && pathSegments.length > 0) {
3026
+ currentPath.setAttribute('d', currentPath.getAttribute('d') + ' ' + pathSegments.join(' '));
3027
+ pathSegments = [];
3028
+ }
3029
+ drawRAF = null;
3030
+ });
3031
+ }
2963
3032
  }
2964
3033
  }
2965
3034
 
2966
3035
  function stopDraw(pageNum) {
3036
+ // Flush any pending path segments before stopping
3037
+ if (drawRAF) {
3038
+ cancelAnimationFrame(drawRAF);
3039
+ drawRAF = null;
3040
+ }
3041
+ if (currentPath && pathSegments.length > 0) {
3042
+ currentPath.setAttribute('d', currentPath.getAttribute('d') + ' ' + pathSegments.join(' '));
3043
+ pathSegments = [];
3044
+ }
3045
+
2967
3046
  // Handle arrow marker
2968
3047
  if (currentTool === 'shape' && currentShape === 'arrow' && currentSvg) {
2969
3048
  const shapeEl = currentSvg.querySelector('.current-shape');