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.
- package/lib/controllers.js +2 -4
- package/library.js +17 -6
- package/package.json +1 -1
- package/static/lib/main.js +29 -4
- package/static/viewer.html +255 -122
package/lib/controllers.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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',
|
|
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',
|
|
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
package/static/lib/main.js
CHANGED
|
@@ -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
|
-
|
|
234
|
-
|
|
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: '
|
|
263
|
+
rootMargin: '0px', // Only trigger when actually visible
|
|
239
264
|
threshold: 0
|
|
240
265
|
});
|
|
241
266
|
|
package/static/viewer.html
CHANGED
|
@@ -442,22 +442,46 @@
|
|
|
442
442
|
font-size: 14px;
|
|
443
443
|
white-space: nowrap;
|
|
444
444
|
}
|
|
445
|
-
|
|
446
|
-
.overflowItem
|
|
447
|
-
|
|
448
|
-
|
|
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 {
|
|
452
|
-
|
|
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)
|
|
456
|
-
.toolbarGroup:nth-child(5)
|
|
457
|
-
.toolbarGroup:nth-child(5)
|
|
458
|
-
.toolbarGroup:nth-child(5)
|
|
459
|
-
.toolbarGroup:nth-child(5)
|
|
460
|
-
.toolbarGroup:nth-child(5)
|
|
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"
|
|
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"
|
|
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"
|
|
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
|
-
|
|
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
|
-
//
|
|
2410
|
-
// This
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
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
|
-
//
|
|
2606
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
2726
|
-
|
|
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 (
|
|
2730
|
-
annotationsStore.set(pageNum,
|
|
2731
|
-
annotationRotations.set(pageNum,
|
|
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
|
|
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
|
|
2758
|
-
|
|
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 (
|
|
2762
|
-
annotationsStore.set(pageNum,
|
|
2763
|
-
annotationRotations.set(pageNum,
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
if (
|
|
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
|
-
|
|
4249
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|