nodebb-plugin-pdf-secure 1.2.11 → 1.2.13
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 +5 -5
- package/package.json +1 -1
- package/static/lib/main.js +4 -2
- package/static/viewer.html +191 -42
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
|
@@ -54,14 +54,14 @@ plugin.init = async (params) => {
|
|
|
54
54
|
next();
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
// PDF binary endpoint (nonce-validated)
|
|
58
|
-
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);
|
|
59
59
|
|
|
60
60
|
// Admin page route
|
|
61
61
|
routeHelpers.setupAdminPageRoute(router, '/admin/plugins/pdf-secure', controllers.renderAdminPage);
|
|
62
62
|
|
|
63
|
-
// Viewer page route (fullscreen Mozilla PDF.js viewer)
|
|
64
|
-
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) => {
|
|
65
65
|
const { file } = req.query;
|
|
66
66
|
if (!file) {
|
|
67
67
|
return res.status(400).send('Missing file parameter');
|
|
@@ -81,7 +81,7 @@ plugin.init = async (params) => {
|
|
|
81
81
|
// Generate nonce + key HERE (in viewer route)
|
|
82
82
|
// This way the key is ONLY embedded in HTML, never in a separate API response
|
|
83
83
|
const isPremium = true;
|
|
84
|
-
const nonceData = nonceStore.generate(req.uid, safeName, isPremium);
|
|
84
|
+
const nonceData = nonceStore.generate(req.uid || 0, safeName, isPremium);
|
|
85
85
|
|
|
86
86
|
// Serve the viewer template with comprehensive security headers
|
|
87
87
|
res.set({
|
package/package.json
CHANGED
package/static/lib/main.js
CHANGED
|
@@ -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:
|
|
80
|
-
}, event.origin, [
|
|
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
|
package/static/viewer.html
CHANGED
|
@@ -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 out of iframe */
|
|
56
|
+
overscroll-behavior: contain;
|
|
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 to parent/iframe on touch devices */
|
|
630
|
+
overscroll-behavior: contain;
|
|
631
|
+
-webkit-overflow-scrolling: touch;
|
|
627
632
|
}
|
|
628
633
|
|
|
629
634
|
#viewerContainer.withSidebar {
|
|
@@ -1287,10 +1292,12 @@
|
|
|
1287
1292
|
display: block !important;
|
|
1288
1293
|
min-width: unset;
|
|
1289
1294
|
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.4);
|
|
1295
|
+
pointer-events: none;
|
|
1290
1296
|
}
|
|
1291
1297
|
|
|
1292
1298
|
.toolDropdown.visible {
|
|
1293
1299
|
transform: translateY(0);
|
|
1300
|
+
pointer-events: auto;
|
|
1294
1301
|
}
|
|
1295
1302
|
|
|
1296
1303
|
/* Responsive dropzone */
|
|
@@ -1454,10 +1461,12 @@
|
|
|
1454
1461
|
display: block !important;
|
|
1455
1462
|
min-width: unset;
|
|
1456
1463
|
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.4);
|
|
1464
|
+
pointer-events: none;
|
|
1457
1465
|
}
|
|
1458
1466
|
|
|
1459
1467
|
.toolDropdown.visible {
|
|
1460
1468
|
transform: translateY(0);
|
|
1469
|
+
pointer-events: auto;
|
|
1461
1470
|
}
|
|
1462
1471
|
|
|
1463
1472
|
/* Selection toolbar & toast above bottom bar */
|
|
@@ -1520,6 +1529,11 @@
|
|
|
1520
1529
|
width: 56px;
|
|
1521
1530
|
height: 56px;
|
|
1522
1531
|
}
|
|
1532
|
+
|
|
1533
|
+
/* Let our JS handle pinch-to-zoom, but allow browser pan-y for scroll */
|
|
1534
|
+
#viewerContainer {
|
|
1535
|
+
touch-action: pan-x pan-y;
|
|
1536
|
+
}
|
|
1523
1537
|
}
|
|
1524
1538
|
</style>
|
|
1525
1539
|
</head>
|
|
@@ -2267,7 +2281,7 @@
|
|
|
2267
2281
|
});
|
|
2268
2282
|
|
|
2269
2283
|
eventBus.on('pagerendered', (evt) => {
|
|
2270
|
-
|
|
2284
|
+
injectAnnotationLayer(evt.pageNumber);
|
|
2271
2285
|
|
|
2272
2286
|
// Rotation is handled natively by PDF.js via pagesRotation
|
|
2273
2287
|
});
|
|
@@ -2350,7 +2364,17 @@
|
|
|
2350
2364
|
const dropdownBackdrop = document.getElementById('dropdownBackdrop');
|
|
2351
2365
|
const overflowDropdown = document.getElementById('overflowDropdown');
|
|
2352
2366
|
|
|
2367
|
+
// Swipe-to-dismiss controller (declared before closeAllDropdowns which references it)
|
|
2368
|
+
let swipeAbortController = null;
|
|
2369
|
+
|
|
2353
2370
|
function closeAllDropdowns() {
|
|
2371
|
+
// Clean up swipe listeners
|
|
2372
|
+
if (swipeAbortController) { swipeAbortController.abort(); swipeAbortController = null; }
|
|
2373
|
+
// Reset inline transform from swipe gesture
|
|
2374
|
+
[highlightDropdown, drawDropdown, shapesDropdown, overflowDropdown].forEach(dd => {
|
|
2375
|
+
dd.style.transform = '';
|
|
2376
|
+
dd.style.transition = '';
|
|
2377
|
+
});
|
|
2354
2378
|
highlightDropdown.classList.remove('visible');
|
|
2355
2379
|
drawDropdown.classList.remove('visible');
|
|
2356
2380
|
shapesDropdown.classList.remove('visible');
|
|
@@ -2374,10 +2398,52 @@
|
|
|
2374
2398
|
// Show backdrop on mobile/tablet portrait
|
|
2375
2399
|
if (useBottomSheet) {
|
|
2376
2400
|
dropdownBackdrop.classList.add('visible');
|
|
2401
|
+
setupBottomSheetSwipe(dropdown);
|
|
2377
2402
|
}
|
|
2378
2403
|
}
|
|
2379
2404
|
}
|
|
2380
2405
|
|
|
2406
|
+
// Bottom sheet swipe-to-dismiss (uses AbortController to prevent listener accumulation)
|
|
2407
|
+
function setupBottomSheetSwipe(dropdown) {
|
|
2408
|
+
// Abort previous swipe listeners if any
|
|
2409
|
+
if (swipeAbortController) swipeAbortController.abort();
|
|
2410
|
+
swipeAbortController = new AbortController();
|
|
2411
|
+
const signal = swipeAbortController.signal;
|
|
2412
|
+
|
|
2413
|
+
let startY = 0, currentY = 0, isDragging = false;
|
|
2414
|
+
|
|
2415
|
+
dropdown.addEventListener('touchstart', (e) => {
|
|
2416
|
+
const rect = dropdown.getBoundingClientRect();
|
|
2417
|
+
const touchY = e.touches[0].clientY;
|
|
2418
|
+
if (touchY - rect.top > 40 && dropdown.scrollTop > 0) return;
|
|
2419
|
+
startY = touchY;
|
|
2420
|
+
currentY = startY;
|
|
2421
|
+
isDragging = true;
|
|
2422
|
+
dropdown.style.transition = 'none';
|
|
2423
|
+
}, { signal });
|
|
2424
|
+
|
|
2425
|
+
dropdown.addEventListener('touchmove', (e) => {
|
|
2426
|
+
if (!isDragging) return;
|
|
2427
|
+
currentY = e.touches[0].clientY;
|
|
2428
|
+
const dy = currentY - startY;
|
|
2429
|
+
if (dy > 0) {
|
|
2430
|
+
dropdown.style.transform = `translateY(${dy}px)`;
|
|
2431
|
+
e.preventDefault();
|
|
2432
|
+
}
|
|
2433
|
+
}, { passive: false, signal });
|
|
2434
|
+
|
|
2435
|
+
dropdown.addEventListener('touchend', () => {
|
|
2436
|
+
if (!isDragging) return;
|
|
2437
|
+
isDragging = false;
|
|
2438
|
+
const dy = currentY - startY;
|
|
2439
|
+
dropdown.style.transition = '';
|
|
2440
|
+
if (dy > 80) {
|
|
2441
|
+
closeAllDropdowns();
|
|
2442
|
+
}
|
|
2443
|
+
dropdown.style.transform = '';
|
|
2444
|
+
}, { signal });
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2381
2447
|
// Backdrop click closes dropdowns
|
|
2382
2448
|
dropdownBackdrop.addEventListener('click', () => {
|
|
2383
2449
|
closeAllDropdowns();
|
|
@@ -2881,7 +2947,7 @@
|
|
|
2881
2947
|
const scaleY = vb.scaleY;
|
|
2882
2948
|
|
|
2883
2949
|
if (currentTool === 'eraser') {
|
|
2884
|
-
eraseAt(svg, x, y, scaleX);
|
|
2950
|
+
eraseAt(svg, x, y, scaleX, coords.clientX, coords.clientY);
|
|
2885
2951
|
// Performance: Don't save here - isDrawing is true, stopDraw will save on mouseup
|
|
2886
2952
|
return;
|
|
2887
2953
|
}
|
|
@@ -2982,7 +3048,7 @@
|
|
|
2982
3048
|
const scaleX = vb.scaleX;
|
|
2983
3049
|
|
|
2984
3050
|
if (currentTool === 'eraser') {
|
|
2985
|
-
eraseAt(svg, x, y, scaleX);
|
|
3051
|
+
eraseAt(svg, x, y, scaleX, coords.clientX, coords.clientY);
|
|
2986
3052
|
// Performance: Don't save on every mousemove - let mouseup handle it
|
|
2987
3053
|
return;
|
|
2988
3054
|
}
|
|
@@ -3390,13 +3456,12 @@
|
|
|
3390
3456
|
});
|
|
3391
3457
|
}
|
|
3392
3458
|
|
|
3393
|
-
function eraseAt(svg, x, y, scale = 1) {
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
if (
|
|
3399
|
-
y >= bbox.y - hitRadius && y <= bbox.y + bbox.height + hitRadius) {
|
|
3459
|
+
function eraseAt(svg, x, y, scale = 1, clientX, clientY) {
|
|
3460
|
+
// Use elementsFromPoint for rotation-aware hit testing
|
|
3461
|
+
const annotationTags = new Set(['path', 'text', 'rect', 'ellipse', 'line']);
|
|
3462
|
+
const elements = document.elementsFromPoint(clientX, clientY);
|
|
3463
|
+
elements.forEach(el => {
|
|
3464
|
+
if (el.closest('.annotationLayer') === svg && annotationTags.has(el.tagName)) {
|
|
3400
3465
|
el.remove();
|
|
3401
3466
|
}
|
|
3402
3467
|
});
|
|
@@ -4322,12 +4387,16 @@
|
|
|
4322
4387
|
// ERGONOMIC FEATURES
|
|
4323
4388
|
// ==========================================
|
|
4324
4389
|
|
|
4325
|
-
// Fullscreen toggle function
|
|
4390
|
+
// Fullscreen toggle function (with webkit fallback for iOS Safari)
|
|
4326
4391
|
function toggleFullscreen() {
|
|
4327
|
-
|
|
4328
|
-
|
|
4392
|
+
const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
|
|
4393
|
+
if (fsEl) {
|
|
4394
|
+
if (document.exitFullscreen) document.exitFullscreen();
|
|
4395
|
+
else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
|
|
4329
4396
|
} else {
|
|
4330
|
-
document.documentElement
|
|
4397
|
+
const el = document.documentElement;
|
|
4398
|
+
if (el.requestFullscreen) el.requestFullscreen().catch(() => {});
|
|
4399
|
+
else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen();
|
|
4331
4400
|
}
|
|
4332
4401
|
}
|
|
4333
4402
|
|
|
@@ -4335,7 +4404,8 @@
|
|
|
4335
4404
|
function updateFullscreenIcon() {
|
|
4336
4405
|
const icon = document.getElementById('fullscreenIcon');
|
|
4337
4406
|
const btn = document.getElementById('fullscreenBtn');
|
|
4338
|
-
|
|
4407
|
+
const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
|
|
4408
|
+
if (fsEl) {
|
|
4339
4409
|
icon.innerHTML = '<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>';
|
|
4340
4410
|
btn.classList.add('active');
|
|
4341
4411
|
} else {
|
|
@@ -4345,31 +4415,12 @@
|
|
|
4345
4415
|
}
|
|
4346
4416
|
|
|
4347
4417
|
document.addEventListener('fullscreenchange', updateFullscreenIcon);
|
|
4418
|
+
document.addEventListener('webkitfullscreenchange', updateFullscreenIcon);
|
|
4348
4419
|
|
|
4349
4420
|
// Fullscreen button click
|
|
4350
4421
|
document.getElementById('fullscreenBtn').onclick = () => toggleFullscreen();
|
|
4351
4422
|
|
|
4352
|
-
// Double-click on page for fullscreen
|
|
4353
|
-
let lastClickTime = 0;
|
|
4354
|
-
container.addEventListener('click', (e) => {
|
|
4355
|
-
const now = Date.now();
|
|
4356
|
-
if (now - lastClickTime < 300) {
|
|
4357
|
-
toggleFullscreen();
|
|
4358
|
-
}
|
|
4359
|
-
lastClickTime = now;
|
|
4360
|
-
});
|
|
4361
4423
|
|
|
4362
|
-
// Auto-fullscreen when viewer loads inside iframe
|
|
4363
|
-
if (window.self !== window.top && window.PDF_SECURE_CONFIG) {
|
|
4364
|
-
// We're inside an iframe - request fullscreen on first user interaction
|
|
4365
|
-
const autoFullscreen = () => {
|
|
4366
|
-
document.documentElement.requestFullscreen().catch(() => { });
|
|
4367
|
-
container.removeEventListener('click', autoFullscreen);
|
|
4368
|
-
container.removeEventListener('touchstart', autoFullscreen);
|
|
4369
|
-
};
|
|
4370
|
-
container.addEventListener('click', autoFullscreen, { once: true });
|
|
4371
|
-
container.addEventListener('touchstart', autoFullscreen, { once: true });
|
|
4372
|
-
}
|
|
4373
4424
|
|
|
4374
4425
|
// Mouse wheel zoom with Ctrl
|
|
4375
4426
|
container.addEventListener('wheel', (e) => {
|
|
@@ -4453,17 +4504,25 @@
|
|
|
4453
4504
|
setupResponsiveToolbar();
|
|
4454
4505
|
});
|
|
4455
4506
|
|
|
4456
|
-
// Also handle resize for orientation changes
|
|
4507
|
+
// Also handle resize for orientation changes (debounced)
|
|
4508
|
+
let resizeTimer;
|
|
4457
4509
|
window.addEventListener('resize', () => {
|
|
4458
|
-
|
|
4510
|
+
clearTimeout(resizeTimer);
|
|
4511
|
+
resizeTimer = setTimeout(setupResponsiveToolbar, 150);
|
|
4459
4512
|
});
|
|
4460
4513
|
|
|
4461
4514
|
// ==========================================
|
|
4462
|
-
// PINCH-TO-ZOOM (Touch devices)
|
|
4515
|
+
// PINCH-TO-ZOOM (Touch devices) - Smooth CSS Transform
|
|
4463
4516
|
// ==========================================
|
|
4464
4517
|
let pinchStartDistance = 0;
|
|
4465
4518
|
let pinchStartScale = 1;
|
|
4466
4519
|
let isPinching = false;
|
|
4520
|
+
let pinchMidX = 0;
|
|
4521
|
+
let pinchMidY = 0;
|
|
4522
|
+
let pinchVisualRatio = 1;
|
|
4523
|
+
|
|
4524
|
+
// The viewer div that holds PDF pages
|
|
4525
|
+
const viewerDiv = document.getElementById('viewer');
|
|
4467
4526
|
|
|
4468
4527
|
function getTouchDistance(touches) {
|
|
4469
4528
|
const dx = touches[0].clientX - touches[1].clientX;
|
|
@@ -4471,11 +4530,40 @@
|
|
|
4471
4530
|
return Math.sqrt(dx * dx + dy * dy);
|
|
4472
4531
|
}
|
|
4473
4532
|
|
|
4533
|
+
function getTouchMidpoint(touches) {
|
|
4534
|
+
return {
|
|
4535
|
+
x: (touches[0].clientX + touches[1].clientX) / 2,
|
|
4536
|
+
y: (touches[0].clientY + touches[1].clientY) / 2
|
|
4537
|
+
};
|
|
4538
|
+
}
|
|
4539
|
+
|
|
4474
4540
|
container.addEventListener('touchstart', (e) => {
|
|
4475
|
-
if (e.touches.length === 2
|
|
4541
|
+
if (e.touches.length === 2) {
|
|
4542
|
+
// Cancel any active drawing and clean up
|
|
4543
|
+
if (isDrawing && currentDrawingPage) {
|
|
4544
|
+
if (currentPath && currentPath.parentNode) currentPath.remove();
|
|
4545
|
+
currentPath = null;
|
|
4546
|
+
pathSegments = [];
|
|
4547
|
+
if (drawRAF) { cancelAnimationFrame(drawRAF); drawRAF = null; }
|
|
4548
|
+
isDrawing = false;
|
|
4549
|
+
currentSvg = null;
|
|
4550
|
+
currentDrawingPage = null;
|
|
4551
|
+
}
|
|
4476
4552
|
isPinching = true;
|
|
4477
4553
|
pinchStartDistance = getTouchDistance(e.touches);
|
|
4478
4554
|
pinchStartScale = pdfViewer.currentScale;
|
|
4555
|
+
pinchVisualRatio = 1;
|
|
4556
|
+
|
|
4557
|
+
// Get midpoint relative to container
|
|
4558
|
+
const mid = getTouchMidpoint(e.touches);
|
|
4559
|
+
const rect = container.getBoundingClientRect();
|
|
4560
|
+
pinchMidX = mid.x - rect.left + container.scrollLeft;
|
|
4561
|
+
pinchMidY = mid.y - rect.top + container.scrollTop;
|
|
4562
|
+
|
|
4563
|
+
// Prepare for CSS transform - use will-change for GPU acceleration
|
|
4564
|
+
viewerDiv.style.willChange = 'transform';
|
|
4565
|
+
viewerDiv.style.transformOrigin = pinchMidX + 'px ' + pinchMidY + 'px';
|
|
4566
|
+
|
|
4479
4567
|
e.preventDefault();
|
|
4480
4568
|
}
|
|
4481
4569
|
}, { passive: false });
|
|
@@ -4484,18 +4572,79 @@
|
|
|
4484
4572
|
if (isPinching && e.touches.length === 2) {
|
|
4485
4573
|
const dist = getTouchDistance(e.touches);
|
|
4486
4574
|
const ratio = dist / pinchStartDistance;
|
|
4487
|
-
|
|
4488
|
-
|
|
4575
|
+
// Clamp the target scale
|
|
4576
|
+
const targetScale = pinchStartScale * ratio;
|
|
4577
|
+
const clampedScale = Math.min(Math.max(targetScale, 0.5), 5.0);
|
|
4578
|
+
pinchVisualRatio = clampedScale / pinchStartScale;
|
|
4579
|
+
|
|
4580
|
+
// Apply CSS transform for instant smooth visual feedback (no re-render)
|
|
4581
|
+
viewerDiv.style.transform = 'scale(' + pinchVisualRatio + ')';
|
|
4582
|
+
|
|
4489
4583
|
e.preventDefault();
|
|
4490
4584
|
}
|
|
4491
4585
|
}, { passive: false });
|
|
4492
4586
|
|
|
4493
4587
|
container.addEventListener('touchend', (e) => {
|
|
4494
|
-
if (e.touches.length < 2) {
|
|
4588
|
+
if (isPinching && e.touches.length < 2) {
|
|
4589
|
+
isPinching = false;
|
|
4590
|
+
|
|
4591
|
+
// Remove CSS transform
|
|
4592
|
+
viewerDiv.style.transform = '';
|
|
4593
|
+
viewerDiv.style.willChange = '';
|
|
4594
|
+
viewerDiv.style.transformOrigin = '';
|
|
4595
|
+
|
|
4596
|
+
// Apply actual scale (triggers PDF re-render only once)
|
|
4597
|
+
const finalScale = Math.min(Math.max(pinchStartScale * pinchVisualRatio, 0.5), 5.0);
|
|
4598
|
+
if (Math.abs(finalScale - pdfViewer.currentScale) > 0.01) {
|
|
4599
|
+
pdfViewer.currentScale = finalScale;
|
|
4600
|
+
}
|
|
4601
|
+
pinchVisualRatio = 1;
|
|
4602
|
+
}
|
|
4603
|
+
});
|
|
4604
|
+
|
|
4605
|
+
container.addEventListener('touchcancel', (e) => {
|
|
4606
|
+
if (isPinching) {
|
|
4495
4607
|
isPinching = false;
|
|
4608
|
+
viewerDiv.style.transform = '';
|
|
4609
|
+
viewerDiv.style.willChange = '';
|
|
4610
|
+
viewerDiv.style.transformOrigin = '';
|
|
4611
|
+
pinchVisualRatio = 1;
|
|
4496
4612
|
}
|
|
4497
4613
|
});
|
|
4498
4614
|
|
|
4615
|
+
// ==========================================
|
|
4616
|
+
// TOUCH SCROLL BOUNDARY (Prevent exit on tablet)
|
|
4617
|
+
// ==========================================
|
|
4618
|
+
// On touch devices, prevent scroll from escaping the container
|
|
4619
|
+
// when at the top/bottom boundary. Only fullscreen button should exit.
|
|
4620
|
+
(function initTouchScrollBoundary() {
|
|
4621
|
+
if (!isTouch()) return;
|
|
4622
|
+
|
|
4623
|
+
let touchStartY = 0;
|
|
4624
|
+
|
|
4625
|
+
container.addEventListener('touchstart', (e) => {
|
|
4626
|
+
if (e.touches.length === 1) {
|
|
4627
|
+
touchStartY = e.touches[0].clientY;
|
|
4628
|
+
}
|
|
4629
|
+
}, { passive: true });
|
|
4630
|
+
|
|
4631
|
+
container.addEventListener('touchmove', (e) => {
|
|
4632
|
+
// Skip if pinching
|
|
4633
|
+
if (isPinching || e.touches.length !== 1) return;
|
|
4634
|
+
|
|
4635
|
+
const touchY = e.touches[0].clientY;
|
|
4636
|
+
const deltaY = touchStartY - touchY; // positive = scrolling down
|
|
4637
|
+
|
|
4638
|
+
const atTop = container.scrollTop <= 0;
|
|
4639
|
+
const atBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 1;
|
|
4640
|
+
|
|
4641
|
+
// Prevent overscroll at boundaries
|
|
4642
|
+
if ((atTop && deltaY < 0) || (atBottom && deltaY > 0)) {
|
|
4643
|
+
e.preventDefault();
|
|
4644
|
+
}
|
|
4645
|
+
}, { passive: false });
|
|
4646
|
+
})();
|
|
4647
|
+
|
|
4499
4648
|
// ==========================================
|
|
4500
4649
|
// CONTEXT MENU TOUCH HANDLING
|
|
4501
4650
|
// ==========================================
|