stimulus-pdf-viewer-rails 0.4.0 → 0.5.0
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.
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 838e9d480a11d8bbf5a590bad2ab618624c1ec95284a1b7aa28b2c40468447ab
|
|
4
|
+
data.tar.gz: 7296bfffca896a43fb16a50a3d271a790eab5541d3744aef8df90ae336e2767a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 32804a93259414ccee6b961564c6587204c6deadcbf5bf07de69d1872751eb125c89099ec5e69125934f7a4ca85974b9bdf5b2b620912725bccb599ce27e082e
|
|
7
|
+
data.tar.gz: '099ea9e6b131bfba5f48b4a63818c03841e05b5da782f51adca84418bc9ee3538b8cc47982e169e76386acaa7c125278bb3794f53ac15a021efd152c1bbf9c37'
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.5.0] - 2026-08-11
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Updated stimulus-pdf-viewer to 0.5.0
|
|
9
|
+
|
|
10
|
+
Adds support for password-protected PDFs: the viewer prompts for the password
|
|
11
|
+
(with retry on incorrect entry) and opens encrypted documents in read-only
|
|
12
|
+
mode — annotation tools are disabled and the download button delivers the
|
|
13
|
+
original file. The vendored stylesheet includes new rules for the password
|
|
14
|
+
prompt and read-only mode, so recompile your assets after upgrading. See the
|
|
15
|
+
upstream
|
|
16
|
+
[0.5.0 changelog](https://github.com/jhubert/stimulus-pdf-viewer/blob/main/CHANGELOG.md)
|
|
17
|
+
for full details.
|
|
18
|
+
|
|
19
|
+
## [0.4.1] - 2026-08-10
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- Updated stimulus-pdf-viewer to 0.4.1
|
|
23
|
+
|
|
24
|
+
This fixes `initialAnnotation` deep-links, which never scrolled to or selected
|
|
25
|
+
the annotation (a string/number id mismatch in the annotation map lookup), and
|
|
26
|
+
gives deep-linked annotations the same flash/highlight treatment as sidebar
|
|
27
|
+
clicks. See the upstream
|
|
28
|
+
[0.4.1 changelog](https://github.com/jhubert/stimulus-pdf-viewer/blob/main/CHANGELOG.md)
|
|
29
|
+
for full details.
|
|
30
|
+
|
|
5
31
|
## [0.4.0] - 2026-06-24
|
|
6
32
|
|
|
7
33
|
### Added
|
|
@@ -369,11 +369,21 @@ class CoreViewer {
|
|
|
369
369
|
this.container = container;
|
|
370
370
|
this.eventBus = options.eventBus || new EventBus();
|
|
371
371
|
|
|
372
|
+
// Called when the document needs a password: ({ retry }) => Promise<string>.
|
|
373
|
+
// Resolve with the password, reject to abort the load. When absent,
|
|
374
|
+
// password-protected documents fail to load with a PasswordException.
|
|
375
|
+
this.onPasswordRequest = options.onPasswordRequest || null;
|
|
376
|
+
|
|
372
377
|
// PDF.js document reference
|
|
373
378
|
this.pdfDocument = null;
|
|
374
379
|
this._loadingTask = null;
|
|
375
380
|
this.pageCount = 0;
|
|
376
381
|
|
|
382
|
+
// Whether the loaded document is encrypted (user- or owner-password).
|
|
383
|
+
// Valid after load() resolves.
|
|
384
|
+
this.isEncrypted = false;
|
|
385
|
+
this._lastPassword = null;
|
|
386
|
+
|
|
377
387
|
// Page data storage: pageNumber -> PageData
|
|
378
388
|
this.pages = new Map();
|
|
379
389
|
|
|
@@ -434,6 +444,8 @@ class CoreViewer {
|
|
|
434
444
|
* @returns {Promise<PDFDocumentProxy>}
|
|
435
445
|
*/
|
|
436
446
|
async load(url) {
|
|
447
|
+
this._lastPassword = null;
|
|
448
|
+
this.isEncrypted = false;
|
|
437
449
|
try {
|
|
438
450
|
return await this._loadDocument(url)
|
|
439
451
|
} catch (error) {
|
|
@@ -445,7 +457,10 @@ class CoreViewer {
|
|
|
445
457
|
throw new Error(`HTTP ${response.status} fetching PDF`)
|
|
446
458
|
}
|
|
447
459
|
const data = new Uint8Array(await response.arrayBuffer());
|
|
448
|
-
|
|
460
|
+
// Reuse a password entered during the streamed attempt so the
|
|
461
|
+
// fallback doesn't prompt the user a second time
|
|
462
|
+
const source = this._lastPassword ? { data, password: this._lastPassword } : { data };
|
|
463
|
+
return await this._loadDocument(source)
|
|
449
464
|
} catch (retryError) {
|
|
450
465
|
console.error("PDF blob fallback also failed:", retryError);
|
|
451
466
|
this.eventBus.dispatch(ViewerEvents.DOCUMENT_LOAD_ERROR, { error: retryError });
|
|
@@ -471,9 +486,32 @@ class CoreViewer {
|
|
|
471
486
|
await this._teardownDocument();
|
|
472
487
|
|
|
473
488
|
this._loadingTask = pdfjsLib.getDocument(source);
|
|
489
|
+
|
|
490
|
+
// Prompt for a password instead of failing with PasswordException. PDF.js
|
|
491
|
+
// calls onPassword again with INCORRECT_PASSWORD after a wrong attempt;
|
|
492
|
+
// passing an Error to updatePassword aborts the load.
|
|
493
|
+
if (this.onPasswordRequest) {
|
|
494
|
+
this._loadingTask.onPassword = (updatePassword, reason) => {
|
|
495
|
+
const retry = reason === pdfjsLib.PasswordResponses.INCORRECT_PASSWORD;
|
|
496
|
+
Promise.resolve(this.onPasswordRequest({ retry }))
|
|
497
|
+
.then(password => {
|
|
498
|
+
this._lastPassword = password;
|
|
499
|
+
updatePassword(password);
|
|
500
|
+
})
|
|
501
|
+
.catch(error => {
|
|
502
|
+
updatePassword(error instanceof Error ? error : new Error("Password entry cancelled"));
|
|
503
|
+
});
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
474
507
|
this.pdfDocument = await this._loadingTask.promise;
|
|
475
508
|
this.pageCount = this.pdfDocument.numPages;
|
|
476
509
|
|
|
510
|
+
// getPermissions() returns null for unencrypted documents. Any encryption
|
|
511
|
+
// (user- or owner-password) makes the document read-only downstream:
|
|
512
|
+
// pdf-lib can't open encrypted files to embed annotations on download.
|
|
513
|
+
this.isEncrypted = (await this.pdfDocument.getPermissions()) !== null;
|
|
514
|
+
|
|
477
515
|
// Set initial display scale on container
|
|
478
516
|
this.container.style.setProperty("--display-scale", String(this.displayScale));
|
|
479
517
|
|
|
@@ -1729,7 +1767,7 @@ class AnnotationManager {
|
|
|
1729
1767
|
this.annotationsByPage.clear();
|
|
1730
1768
|
|
|
1731
1769
|
for (const annotation of annotationsData) {
|
|
1732
|
-
this.annotations.set(annotation.id, annotation);
|
|
1770
|
+
this.annotations.set(this._key(annotation.id), annotation);
|
|
1733
1771
|
|
|
1734
1772
|
if (!this.annotationsByPage.has(annotation.page)) {
|
|
1735
1773
|
this.annotationsByPage.set(annotation.page, []);
|
|
@@ -1738,8 +1776,14 @@ class AnnotationManager {
|
|
|
1738
1776
|
}
|
|
1739
1777
|
}
|
|
1740
1778
|
|
|
1779
|
+
// Ids arrive as numbers from JSON payloads but as strings from DOM
|
|
1780
|
+
// datasets and Stimulus values, so the map is keyed by string
|
|
1781
|
+
_key(id) {
|
|
1782
|
+
return String(id)
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1741
1785
|
getAnnotation(id) {
|
|
1742
|
-
return this.annotations.get(id)
|
|
1786
|
+
return this.annotations.get(this._key(id))
|
|
1743
1787
|
}
|
|
1744
1788
|
|
|
1745
1789
|
getAnnotationsForPage(pageNumber) {
|
|
@@ -1785,7 +1829,7 @@ class AnnotationManager {
|
|
|
1785
1829
|
}
|
|
1786
1830
|
|
|
1787
1831
|
async deleteAnnotation(id) {
|
|
1788
|
-
const existingAnnotation = this.
|
|
1832
|
+
const existingAnnotation = this.getAnnotation(id);
|
|
1789
1833
|
if (!existingAnnotation) return
|
|
1790
1834
|
|
|
1791
1835
|
try {
|
|
@@ -1824,7 +1868,7 @@ class AnnotationManager {
|
|
|
1824
1868
|
}
|
|
1825
1869
|
|
|
1826
1870
|
_addAnnotation(annotation) {
|
|
1827
|
-
this.annotations.set(annotation.id, annotation);
|
|
1871
|
+
this.annotations.set(this._key(annotation.id), annotation);
|
|
1828
1872
|
|
|
1829
1873
|
if (!this.annotationsByPage.has(annotation.page)) {
|
|
1830
1874
|
this.annotationsByPage.set(annotation.page, []);
|
|
@@ -1833,7 +1877,7 @@ class AnnotationManager {
|
|
|
1833
1877
|
}
|
|
1834
1878
|
|
|
1835
1879
|
_updateAnnotation(annotation) {
|
|
1836
|
-
const oldAnnotation = this.
|
|
1880
|
+
const oldAnnotation = this.getAnnotation(annotation.id);
|
|
1837
1881
|
if (!oldAnnotation) {
|
|
1838
1882
|
this._addAnnotation(annotation);
|
|
1839
1883
|
return
|
|
@@ -1850,27 +1894,27 @@ class AnnotationManager {
|
|
|
1850
1894
|
} else {
|
|
1851
1895
|
// Update in place
|
|
1852
1896
|
const pageAnnotations = this.annotationsByPage.get(annotation.page);
|
|
1853
|
-
const index = pageAnnotations.findIndex(a => a.id === annotation.id);
|
|
1897
|
+
const index = pageAnnotations.findIndex(a => this._key(a.id) === this._key(annotation.id));
|
|
1854
1898
|
if (index !== -1) {
|
|
1855
1899
|
pageAnnotations[index] = annotation;
|
|
1856
1900
|
}
|
|
1857
1901
|
}
|
|
1858
1902
|
|
|
1859
|
-
this.annotations.set(annotation.id, annotation);
|
|
1903
|
+
this.annotations.set(this._key(annotation.id), annotation);
|
|
1860
1904
|
}
|
|
1861
1905
|
|
|
1862
1906
|
_removeAnnotation(id) {
|
|
1863
|
-
const annotation = this.
|
|
1907
|
+
const annotation = this.getAnnotation(id);
|
|
1864
1908
|
if (!annotation) return
|
|
1865
1909
|
|
|
1866
1910
|
this._removeAnnotationFromPage(id, annotation.page);
|
|
1867
|
-
this.annotations.delete(id);
|
|
1911
|
+
this.annotations.delete(this._key(id));
|
|
1868
1912
|
}
|
|
1869
1913
|
|
|
1870
1914
|
_removeAnnotationFromPage(id, pageNumber) {
|
|
1871
1915
|
const pageAnnotations = this.annotationsByPage.get(pageNumber);
|
|
1872
1916
|
if (pageAnnotations) {
|
|
1873
|
-
const index = pageAnnotations.findIndex(a => a.id === id);
|
|
1917
|
+
const index = pageAnnotations.findIndex(a => this._key(a.id) === this._key(id));
|
|
1874
1918
|
if (index !== -1) {
|
|
1875
1919
|
pageAnnotations.splice(index, 1);
|
|
1876
1920
|
}
|
|
@@ -1982,6 +2026,16 @@ class DownloadManager {
|
|
|
1982
2026
|
this._triggerDownload(pdfBytes, filename);
|
|
1983
2027
|
}
|
|
1984
2028
|
|
|
2029
|
+
// Download the original file untouched. Used for encrypted documents, which
|
|
2030
|
+
// pdf-lib can't open to embed annotations or the watermark.
|
|
2031
|
+
async downloadOriginal() {
|
|
2032
|
+
const request = new FetchRequest("get", this.documentUrl, { responseKind: "blob" });
|
|
2033
|
+
const response = await request.perform();
|
|
2034
|
+
const pdfBytes = await response.response.arrayBuffer();
|
|
2035
|
+
const filename = this._sanitizeFilename(this.documentName || "document");
|
|
2036
|
+
this._triggerDownload(pdfBytes, filename);
|
|
2037
|
+
}
|
|
2038
|
+
|
|
1985
2039
|
_applyWatermarkToPage(page, font, width, height) {
|
|
1986
2040
|
if (!this.userName) return
|
|
1987
2041
|
|
|
@@ -4363,7 +4417,7 @@ class AnnotationSidebar {
|
|
|
4363
4417
|
}
|
|
4364
4418
|
|
|
4365
4419
|
// Selection state
|
|
4366
|
-
if (this.selectedAnnotationId === annotation.id) {
|
|
4420
|
+
if (this.selectedAnnotationId === String(annotation.id)) {
|
|
4367
4421
|
item.classList.add("selected");
|
|
4368
4422
|
}
|
|
4369
4423
|
|
|
@@ -4471,6 +4525,9 @@ class AnnotationSidebar {
|
|
|
4471
4525
|
}
|
|
4472
4526
|
|
|
4473
4527
|
_selectItem(annotationId) {
|
|
4528
|
+
// Ids arrive as numbers from JSON payloads but as strings from DOM
|
|
4529
|
+
// datasets and deep-links, so selection state is tracked as string
|
|
4530
|
+
annotationId = String(annotationId);
|
|
4474
4531
|
const previousId = this.selectedAnnotationId;
|
|
4475
4532
|
|
|
4476
4533
|
// Skip if already selected
|
|
@@ -4527,7 +4584,7 @@ class AnnotationSidebar {
|
|
|
4527
4584
|
onAnnotationDeleted(annotation) {
|
|
4528
4585
|
if (this.isOpen) {
|
|
4529
4586
|
// Clear selection if deleted annotation was selected
|
|
4530
|
-
if (this.selectedAnnotationId === annotation.id) {
|
|
4587
|
+
if (this.selectedAnnotationId === String(annotation.id)) {
|
|
4531
4588
|
const previousId = this.selectedAnnotationId;
|
|
4532
4589
|
this.selectedAnnotationId = null;
|
|
4533
4590
|
this.element.dispatchEvent(new CustomEvent("pdf-sidebar:annotation-deselected", {
|
|
@@ -5599,6 +5656,122 @@ class FindBar {
|
|
|
5599
5656
|
}
|
|
5600
5657
|
}
|
|
5601
5658
|
|
|
5659
|
+
/**
|
|
5660
|
+
* PasswordPrompt - Modal dialog for unlocking password-protected PDFs.
|
|
5661
|
+
*
|
|
5662
|
+
* Shown over the viewer when PDF.js requests a password during document
|
|
5663
|
+
* load. Each request() call returns a promise that resolves with the
|
|
5664
|
+
* entered password, or rejects when the user cancels.
|
|
5665
|
+
*/
|
|
5666
|
+
|
|
5667
|
+
class PasswordPrompt {
|
|
5668
|
+
constructor(options = {}) {
|
|
5669
|
+
this.container = options.container;
|
|
5670
|
+
|
|
5671
|
+
this.element = null;
|
|
5672
|
+
this.inputElement = null;
|
|
5673
|
+
this.errorElement = null;
|
|
5674
|
+
|
|
5675
|
+
this._pending = null; // { resolve, reject } for the in-flight request
|
|
5676
|
+
|
|
5677
|
+
this._createUI();
|
|
5678
|
+
this._setupEventListeners();
|
|
5679
|
+
}
|
|
5680
|
+
|
|
5681
|
+
_createUI() {
|
|
5682
|
+
this.element = document.createElement("div");
|
|
5683
|
+
this.element.className = "pdf-password-prompt hidden";
|
|
5684
|
+
this.element.innerHTML = `
|
|
5685
|
+
<div class="pdf-password-prompt-dialog" role="dialog" aria-modal="true" aria-labelledby="pdf-password-prompt-title">
|
|
5686
|
+
<h2 class="pdf-password-prompt-title" id="pdf-password-prompt-title">Password required</h2>
|
|
5687
|
+
<p class="pdf-password-prompt-message">This document is protected. Enter the password to open it.</p>
|
|
5688
|
+
<p class="pdf-password-prompt-error hidden">Incorrect password. Please try again.</p>
|
|
5689
|
+
<input type="password" class="pdf-password-prompt-input" autocomplete="off" aria-label="Document password">
|
|
5690
|
+
<div class="pdf-password-prompt-buttons">
|
|
5691
|
+
<button type="button" class="pdf-password-prompt-btn pdf-password-prompt-cancel">Cancel</button>
|
|
5692
|
+
<button type="button" class="pdf-password-prompt-btn pdf-password-prompt-submit">Open</button>
|
|
5693
|
+
</div>
|
|
5694
|
+
</div>
|
|
5695
|
+
`;
|
|
5696
|
+
|
|
5697
|
+
this.inputElement = this.element.querySelector(".pdf-password-prompt-input");
|
|
5698
|
+
this.errorElement = this.element.querySelector(".pdf-password-prompt-error");
|
|
5699
|
+
this.submitButton = this.element.querySelector(".pdf-password-prompt-submit");
|
|
5700
|
+
this.cancelButton = this.element.querySelector(".pdf-password-prompt-cancel");
|
|
5701
|
+
|
|
5702
|
+
this.container.appendChild(this.element);
|
|
5703
|
+
}
|
|
5704
|
+
|
|
5705
|
+
_setupEventListeners() {
|
|
5706
|
+
this.submitButton.addEventListener("click", () => this._submit());
|
|
5707
|
+
this.cancelButton.addEventListener("click", () => this._cancel());
|
|
5708
|
+
|
|
5709
|
+
this.element.addEventListener("keydown", (e) => {
|
|
5710
|
+
if (e.key === "Enter") {
|
|
5711
|
+
e.preventDefault();
|
|
5712
|
+
this._submit();
|
|
5713
|
+
} else if (e.key === "Escape") {
|
|
5714
|
+
e.preventDefault();
|
|
5715
|
+
this._cancel();
|
|
5716
|
+
}
|
|
5717
|
+
});
|
|
5718
|
+
}
|
|
5719
|
+
|
|
5720
|
+
/**
|
|
5721
|
+
* Ask the user for the document password.
|
|
5722
|
+
* @param {Object} options
|
|
5723
|
+
* @param {boolean} options.retry - Whether a previous attempt was incorrect
|
|
5724
|
+
* @returns {Promise<string>} Resolves with the password, rejects on cancel
|
|
5725
|
+
*/
|
|
5726
|
+
request({ retry = false } = {}) {
|
|
5727
|
+
return new Promise((resolve, reject) => {
|
|
5728
|
+
// A request should never overlap another, but if it does, cancel the old one
|
|
5729
|
+
this._pending?.reject(new Error("Password entry cancelled"));
|
|
5730
|
+
this._pending = { resolve, reject };
|
|
5731
|
+
|
|
5732
|
+
this.errorElement.classList.toggle("hidden", !retry);
|
|
5733
|
+
this.inputElement.value = "";
|
|
5734
|
+
this.element.classList.remove("hidden");
|
|
5735
|
+
this.inputElement.focus({ preventScroll: true });
|
|
5736
|
+
})
|
|
5737
|
+
}
|
|
5738
|
+
|
|
5739
|
+
_submit() {
|
|
5740
|
+
const password = this.inputElement.value;
|
|
5741
|
+
if (!password) {
|
|
5742
|
+
this.inputElement.focus({ preventScroll: true });
|
|
5743
|
+
return
|
|
5744
|
+
}
|
|
5745
|
+
|
|
5746
|
+
const pending = this._pending;
|
|
5747
|
+
this._pending = null;
|
|
5748
|
+
this._hide();
|
|
5749
|
+
pending?.resolve(password);
|
|
5750
|
+
}
|
|
5751
|
+
|
|
5752
|
+
_cancel() {
|
|
5753
|
+
const pending = this._pending;
|
|
5754
|
+
this._pending = null;
|
|
5755
|
+
this._hide();
|
|
5756
|
+
pending?.reject(new Error("Password entry cancelled"));
|
|
5757
|
+
}
|
|
5758
|
+
|
|
5759
|
+
_hide() {
|
|
5760
|
+
this.element.classList.add("hidden");
|
|
5761
|
+
this.inputElement.value = "";
|
|
5762
|
+
}
|
|
5763
|
+
|
|
5764
|
+
/**
|
|
5765
|
+
* Clean up. Safe to call multiple times.
|
|
5766
|
+
*/
|
|
5767
|
+
destroy() {
|
|
5768
|
+
this._pending?.reject(new Error("Password entry cancelled"));
|
|
5769
|
+
this._pending = null;
|
|
5770
|
+
this.element?.remove();
|
|
5771
|
+
this.element = null;
|
|
5772
|
+
}
|
|
5773
|
+
}
|
|
5774
|
+
|
|
5602
5775
|
class BaseTool {
|
|
5603
5776
|
constructor(pdfViewer) {
|
|
5604
5777
|
this.pdfViewer = pdfViewer;
|
|
@@ -7224,6 +7397,9 @@ class PdfViewer {
|
|
|
7224
7397
|
|
|
7225
7398
|
this.currentTool = null;
|
|
7226
7399
|
this.currentMode = ToolMode.SELECT;
|
|
7400
|
+
// True once an encrypted document is loaded: annotations and annotated
|
|
7401
|
+
// download are disabled (pdf-lib can't open encrypted files)
|
|
7402
|
+
this.readOnly = false;
|
|
7227
7403
|
this.selectedAnnotation = null;
|
|
7228
7404
|
this.selectedAnnotationElement = null;
|
|
7229
7405
|
this.pendingAnnotationSelection = null; // Annotation ID to select when rendered
|
|
@@ -7265,9 +7441,13 @@ class PdfViewer {
|
|
|
7265
7441
|
return
|
|
7266
7442
|
}
|
|
7267
7443
|
|
|
7444
|
+
// Modal shown when a document needs a password to open
|
|
7445
|
+
this.passwordPrompt = new PasswordPrompt({ container: this.container });
|
|
7446
|
+
|
|
7268
7447
|
// Core viewer (PDF.js wrapper with lazy rendering and events)
|
|
7269
7448
|
this.viewer = new CoreViewer(this.pagesContainer, {
|
|
7270
|
-
initialScale: 1.0
|
|
7449
|
+
initialScale: 1.0,
|
|
7450
|
+
onPasswordRequest: ({ retry }) => this.passwordPrompt.request({ retry })
|
|
7271
7451
|
});
|
|
7272
7452
|
|
|
7273
7453
|
// Subscribe to core viewer events
|
|
@@ -7411,9 +7591,11 @@ class PdfViewer {
|
|
|
7411
7591
|
*/
|
|
7412
7592
|
_onDocumentLoaded(pageCount) {
|
|
7413
7593
|
this._currentPage = 1;
|
|
7594
|
+
this.readOnly = this.viewer.isEncrypted;
|
|
7414
7595
|
this._dispatchEvent("pdf-viewer:ready", {
|
|
7415
7596
|
pageCount,
|
|
7416
|
-
currentPage: 1
|
|
7597
|
+
currentPage: 1,
|
|
7598
|
+
readOnly: this.readOnly
|
|
7417
7599
|
});
|
|
7418
7600
|
}
|
|
7419
7601
|
|
|
@@ -7510,11 +7692,12 @@ class PdfViewer {
|
|
|
7510
7692
|
await this.thumbnailSidebar.setDocument(this.viewer.pdfDocument);
|
|
7511
7693
|
}
|
|
7512
7694
|
|
|
7513
|
-
// Load existing annotations from store
|
|
7514
|
-
|
|
7515
|
-
|
|
7516
|
-
|
|
7517
|
-
|
|
7695
|
+
// Load existing annotations from store and render them on all rendered
|
|
7696
|
+
// pages. Skipped for encrypted documents, which are view-only.
|
|
7697
|
+
if (!this.readOnly) {
|
|
7698
|
+
await this.annotationManager.loadAnnotations();
|
|
7699
|
+
this._renderAnnotations();
|
|
7700
|
+
}
|
|
7518
7701
|
|
|
7519
7702
|
const annotations = this.annotationManager.getAllAnnotations();
|
|
7520
7703
|
this.container.dispatchEvent(new CustomEvent("pdf-viewer:annotations-loaded", {
|
|
@@ -7527,9 +7710,10 @@ class PdfViewer {
|
|
|
7527
7710
|
this.viewer.goToPage(this.initialPage);
|
|
7528
7711
|
}
|
|
7529
7712
|
|
|
7530
|
-
// Navigate to initial annotation if specified
|
|
7713
|
+
// Navigate to initial annotation if specified, flashing it so
|
|
7714
|
+
// deep-link visitors can spot it immediately
|
|
7531
7715
|
if (this.initialAnnotation) {
|
|
7532
|
-
this.
|
|
7716
|
+
this._scrollToAnnotationWithFlash(this.initialAnnotation);
|
|
7533
7717
|
}
|
|
7534
7718
|
|
|
7535
7719
|
// Start with select tool
|
|
@@ -7544,6 +7728,11 @@ class PdfViewer {
|
|
|
7544
7728
|
}
|
|
7545
7729
|
|
|
7546
7730
|
setTool(mode) {
|
|
7731
|
+
// Encrypted documents are view-only: only the select tool is allowed
|
|
7732
|
+
if (this.readOnly && mode !== ToolMode.SELECT) {
|
|
7733
|
+
return
|
|
7734
|
+
}
|
|
7735
|
+
|
|
7547
7736
|
// Deactivate current tool
|
|
7548
7737
|
if (this.currentTool) {
|
|
7549
7738
|
this.currentTool.deactivate();
|
|
@@ -8428,20 +8617,9 @@ class PdfViewer {
|
|
|
8428
8617
|
}
|
|
8429
8618
|
}
|
|
8430
8619
|
|
|
8431
|
-
_scrollToAnnotation(annotationId) {
|
|
8432
|
-
const annotation = this.annotationManager.getAnnotation(annotationId);
|
|
8433
|
-
if (!annotation) return
|
|
8434
|
-
|
|
8435
|
-
// Mark this annotation for selection when it's rendered
|
|
8436
|
-
this.pendingAnnotationSelection = annotationId;
|
|
8437
|
-
|
|
8438
|
-
// Go to the page - the annotation will be selected in _renderAnnotationsForPage
|
|
8439
|
-
this.viewer.goToPage(annotation.page);
|
|
8440
|
-
}
|
|
8441
|
-
|
|
8442
8620
|
/**
|
|
8443
8621
|
* Scroll to annotation and flash/highlight it.
|
|
8444
|
-
* Called from the annotation sidebar
|
|
8622
|
+
* Called from the annotation sidebar and for initial deep-links.
|
|
8445
8623
|
*/
|
|
8446
8624
|
_scrollToAnnotationWithFlash(annotationId) {
|
|
8447
8625
|
const annotation = this.annotationManager.getAnnotation(annotationId);
|
|
@@ -8557,10 +8735,15 @@ class PdfViewer {
|
|
|
8557
8735
|
});
|
|
8558
8736
|
}
|
|
8559
8737
|
|
|
8560
|
-
// Download with annotations
|
|
8738
|
+
// Download with annotations (or the original file for encrypted documents,
|
|
8739
|
+
// which pdf-lib can't open to embed annotations)
|
|
8561
8740
|
async download() {
|
|
8562
8741
|
try {
|
|
8563
|
-
|
|
8742
|
+
if (this.readOnly) {
|
|
8743
|
+
await this.downloadManager.downloadOriginal();
|
|
8744
|
+
} else {
|
|
8745
|
+
await this.downloadManager.downloadWithAnnotations();
|
|
8746
|
+
}
|
|
8564
8747
|
} catch (error) {
|
|
8565
8748
|
console.error("Failed to download PDF:", error);
|
|
8566
8749
|
throw error
|
|
@@ -8589,6 +8772,7 @@ class PdfViewer {
|
|
|
8589
8772
|
this.findController?.destroy();
|
|
8590
8773
|
this.findBar?.destroy();
|
|
8591
8774
|
this.colorPicker?.destroy();
|
|
8775
|
+
this.passwordPrompt?.destroy();
|
|
8592
8776
|
|
|
8593
8777
|
Object.values(this.tools || {}).forEach(tool => tool.destroy?.());
|
|
8594
8778
|
|
|
@@ -8658,7 +8842,9 @@ class pdf_viewer_controller extends Controller {
|
|
|
8658
8842
|
if (this.hasLoadingOverlayTarget) {
|
|
8659
8843
|
this.loadingOverlayTarget.classList.add("hidden");
|
|
8660
8844
|
}
|
|
8661
|
-
const
|
|
8845
|
+
const isPasswordError = /password/i.test(String(error?.message || "")) || /password/i.test(String(error?.name || ""));
|
|
8846
|
+
const message = this.errorMessageValue ||
|
|
8847
|
+
(isPasswordError ? "A password is required to open this document" : "Failed to load PDF document");
|
|
8662
8848
|
this._showError(message);
|
|
8663
8849
|
this.containerTarget.dispatchEvent(new CustomEvent("pdf-viewer:load-failed", {
|
|
8664
8850
|
bubbles: true,
|
|
@@ -8839,6 +9025,10 @@ class pdf_viewer_controller extends Controller {
|
|
|
8839
9025
|
}
|
|
8840
9026
|
|
|
8841
9027
|
_activateTool(toolName) {
|
|
9028
|
+
// View-only for encrypted documents; the buttons are disabled, but guard
|
|
9029
|
+
// against keyboard/programmatic activation too
|
|
9030
|
+
if (this._readOnly && toolName !== "select") return
|
|
9031
|
+
|
|
8842
9032
|
// Tool map for name -> mode conversion
|
|
8843
9033
|
const toolMap = {
|
|
8844
9034
|
select: ToolMode.SELECT,
|
|
@@ -9024,8 +9214,8 @@ class pdf_viewer_controller extends Controller {
|
|
|
9024
9214
|
_setupPageNavigationListeners() {
|
|
9025
9215
|
// Listen for ready event from PdfViewer
|
|
9026
9216
|
this._readyHandler = (e) => {
|
|
9027
|
-
const { pageCount, currentPage } = e.detail;
|
|
9028
|
-
this._onViewerReady(pageCount, currentPage);
|
|
9217
|
+
const { pageCount, currentPage, readOnly } = e.detail;
|
|
9218
|
+
this._onViewerReady(pageCount, currentPage, readOnly);
|
|
9029
9219
|
};
|
|
9030
9220
|
this.containerTarget.addEventListener("pdf-viewer:ready", this._readyHandler);
|
|
9031
9221
|
|
|
@@ -9037,12 +9227,16 @@ class pdf_viewer_controller extends Controller {
|
|
|
9037
9227
|
this.containerTarget.addEventListener("pdf-viewer:page-changed", this._pageChangedHandler);
|
|
9038
9228
|
}
|
|
9039
9229
|
|
|
9040
|
-
_onViewerReady(pageCount, currentPage) {
|
|
9230
|
+
_onViewerReady(pageCount, currentPage, readOnly) {
|
|
9041
9231
|
// Hide the loading overlay
|
|
9042
9232
|
if (this.hasLoadingOverlayTarget) {
|
|
9043
9233
|
this.loadingOverlayTarget.classList.add("hidden");
|
|
9044
9234
|
}
|
|
9045
9235
|
|
|
9236
|
+
if (readOnly) {
|
|
9237
|
+
this._enterReadOnlyMode();
|
|
9238
|
+
}
|
|
9239
|
+
|
|
9046
9240
|
if (this.hasPageCountTarget) {
|
|
9047
9241
|
this.pageCountTarget.textContent = pageCount;
|
|
9048
9242
|
}
|
|
@@ -9063,6 +9257,16 @@ class pdf_viewer_controller extends Controller {
|
|
|
9063
9257
|
this._setZoomPreset("auto");
|
|
9064
9258
|
}
|
|
9065
9259
|
|
|
9260
|
+
// Encrypted documents are view-only: disable the annotation tools and hide
|
|
9261
|
+
// the color picker. Download stays enabled but delivers the original file.
|
|
9262
|
+
_enterReadOnlyMode() {
|
|
9263
|
+
this._readOnly = true;
|
|
9264
|
+
this.containerTarget.classList.add("pdf-viewer-read-only");
|
|
9265
|
+
this.containerTarget
|
|
9266
|
+
.querySelectorAll('.pdf-tool-btn[data-tool]:not([data-tool="select"]), .pdf-overflow-tool-btn[data-tool]:not([data-tool="select"])')
|
|
9267
|
+
.forEach(btn => { btn.disabled = true; });
|
|
9268
|
+
}
|
|
9269
|
+
|
|
9066
9270
|
_onPageChanged(currentPage, pageCount) {
|
|
9067
9271
|
if (this.hasPageInputTarget && document.activeElement !== this.pageInputTarget) {
|
|
9068
9272
|
this.pageInputTarget.value = currentPage;
|
|
@@ -389,11 +389,21 @@
|
|
|
389
389
|
this.container = container;
|
|
390
390
|
this.eventBus = options.eventBus || new EventBus();
|
|
391
391
|
|
|
392
|
+
// Called when the document needs a password: ({ retry }) => Promise<string>.
|
|
393
|
+
// Resolve with the password, reject to abort the load. When absent,
|
|
394
|
+
// password-protected documents fail to load with a PasswordException.
|
|
395
|
+
this.onPasswordRequest = options.onPasswordRequest || null;
|
|
396
|
+
|
|
392
397
|
// PDF.js document reference
|
|
393
398
|
this.pdfDocument = null;
|
|
394
399
|
this._loadingTask = null;
|
|
395
400
|
this.pageCount = 0;
|
|
396
401
|
|
|
402
|
+
// Whether the loaded document is encrypted (user- or owner-password).
|
|
403
|
+
// Valid after load() resolves.
|
|
404
|
+
this.isEncrypted = false;
|
|
405
|
+
this._lastPassword = null;
|
|
406
|
+
|
|
397
407
|
// Page data storage: pageNumber -> PageData
|
|
398
408
|
this.pages = new Map();
|
|
399
409
|
|
|
@@ -454,6 +464,8 @@
|
|
|
454
464
|
* @returns {Promise<PDFDocumentProxy>}
|
|
455
465
|
*/
|
|
456
466
|
async load(url) {
|
|
467
|
+
this._lastPassword = null;
|
|
468
|
+
this.isEncrypted = false;
|
|
457
469
|
try {
|
|
458
470
|
return await this._loadDocument(url)
|
|
459
471
|
} catch (error) {
|
|
@@ -465,7 +477,10 @@
|
|
|
465
477
|
throw new Error(`HTTP ${response.status} fetching PDF`)
|
|
466
478
|
}
|
|
467
479
|
const data = new Uint8Array(await response.arrayBuffer());
|
|
468
|
-
|
|
480
|
+
// Reuse a password entered during the streamed attempt so the
|
|
481
|
+
// fallback doesn't prompt the user a second time
|
|
482
|
+
const source = this._lastPassword ? { data, password: this._lastPassword } : { data };
|
|
483
|
+
return await this._loadDocument(source)
|
|
469
484
|
} catch (retryError) {
|
|
470
485
|
console.error("PDF blob fallback also failed:", retryError);
|
|
471
486
|
this.eventBus.dispatch(ViewerEvents.DOCUMENT_LOAD_ERROR, { error: retryError });
|
|
@@ -491,9 +506,32 @@
|
|
|
491
506
|
await this._teardownDocument();
|
|
492
507
|
|
|
493
508
|
this._loadingTask = pdfjsLib__namespace.getDocument(source);
|
|
509
|
+
|
|
510
|
+
// Prompt for a password instead of failing with PasswordException. PDF.js
|
|
511
|
+
// calls onPassword again with INCORRECT_PASSWORD after a wrong attempt;
|
|
512
|
+
// passing an Error to updatePassword aborts the load.
|
|
513
|
+
if (this.onPasswordRequest) {
|
|
514
|
+
this._loadingTask.onPassword = (updatePassword, reason) => {
|
|
515
|
+
const retry = reason === pdfjsLib__namespace.PasswordResponses.INCORRECT_PASSWORD;
|
|
516
|
+
Promise.resolve(this.onPasswordRequest({ retry }))
|
|
517
|
+
.then(password => {
|
|
518
|
+
this._lastPassword = password;
|
|
519
|
+
updatePassword(password);
|
|
520
|
+
})
|
|
521
|
+
.catch(error => {
|
|
522
|
+
updatePassword(error instanceof Error ? error : new Error("Password entry cancelled"));
|
|
523
|
+
});
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
494
527
|
this.pdfDocument = await this._loadingTask.promise;
|
|
495
528
|
this.pageCount = this.pdfDocument.numPages;
|
|
496
529
|
|
|
530
|
+
// getPermissions() returns null for unencrypted documents. Any encryption
|
|
531
|
+
// (user- or owner-password) makes the document read-only downstream:
|
|
532
|
+
// pdf-lib can't open encrypted files to embed annotations on download.
|
|
533
|
+
this.isEncrypted = (await this.pdfDocument.getPermissions()) !== null;
|
|
534
|
+
|
|
497
535
|
// Set initial display scale on container
|
|
498
536
|
this.container.style.setProperty("--display-scale", String(this.displayScale));
|
|
499
537
|
|
|
@@ -1749,7 +1787,7 @@
|
|
|
1749
1787
|
this.annotationsByPage.clear();
|
|
1750
1788
|
|
|
1751
1789
|
for (const annotation of annotationsData) {
|
|
1752
|
-
this.annotations.set(annotation.id, annotation);
|
|
1790
|
+
this.annotations.set(this._key(annotation.id), annotation);
|
|
1753
1791
|
|
|
1754
1792
|
if (!this.annotationsByPage.has(annotation.page)) {
|
|
1755
1793
|
this.annotationsByPage.set(annotation.page, []);
|
|
@@ -1758,8 +1796,14 @@
|
|
|
1758
1796
|
}
|
|
1759
1797
|
}
|
|
1760
1798
|
|
|
1799
|
+
// Ids arrive as numbers from JSON payloads but as strings from DOM
|
|
1800
|
+
// datasets and Stimulus values, so the map is keyed by string
|
|
1801
|
+
_key(id) {
|
|
1802
|
+
return String(id)
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1761
1805
|
getAnnotation(id) {
|
|
1762
|
-
return this.annotations.get(id)
|
|
1806
|
+
return this.annotations.get(this._key(id))
|
|
1763
1807
|
}
|
|
1764
1808
|
|
|
1765
1809
|
getAnnotationsForPage(pageNumber) {
|
|
@@ -1805,7 +1849,7 @@
|
|
|
1805
1849
|
}
|
|
1806
1850
|
|
|
1807
1851
|
async deleteAnnotation(id) {
|
|
1808
|
-
const existingAnnotation = this.
|
|
1852
|
+
const existingAnnotation = this.getAnnotation(id);
|
|
1809
1853
|
if (!existingAnnotation) return
|
|
1810
1854
|
|
|
1811
1855
|
try {
|
|
@@ -1844,7 +1888,7 @@
|
|
|
1844
1888
|
}
|
|
1845
1889
|
|
|
1846
1890
|
_addAnnotation(annotation) {
|
|
1847
|
-
this.annotations.set(annotation.id, annotation);
|
|
1891
|
+
this.annotations.set(this._key(annotation.id), annotation);
|
|
1848
1892
|
|
|
1849
1893
|
if (!this.annotationsByPage.has(annotation.page)) {
|
|
1850
1894
|
this.annotationsByPage.set(annotation.page, []);
|
|
@@ -1853,7 +1897,7 @@
|
|
|
1853
1897
|
}
|
|
1854
1898
|
|
|
1855
1899
|
_updateAnnotation(annotation) {
|
|
1856
|
-
const oldAnnotation = this.
|
|
1900
|
+
const oldAnnotation = this.getAnnotation(annotation.id);
|
|
1857
1901
|
if (!oldAnnotation) {
|
|
1858
1902
|
this._addAnnotation(annotation);
|
|
1859
1903
|
return
|
|
@@ -1870,27 +1914,27 @@
|
|
|
1870
1914
|
} else {
|
|
1871
1915
|
// Update in place
|
|
1872
1916
|
const pageAnnotations = this.annotationsByPage.get(annotation.page);
|
|
1873
|
-
const index = pageAnnotations.findIndex(a => a.id === annotation.id);
|
|
1917
|
+
const index = pageAnnotations.findIndex(a => this._key(a.id) === this._key(annotation.id));
|
|
1874
1918
|
if (index !== -1) {
|
|
1875
1919
|
pageAnnotations[index] = annotation;
|
|
1876
1920
|
}
|
|
1877
1921
|
}
|
|
1878
1922
|
|
|
1879
|
-
this.annotations.set(annotation.id, annotation);
|
|
1923
|
+
this.annotations.set(this._key(annotation.id), annotation);
|
|
1880
1924
|
}
|
|
1881
1925
|
|
|
1882
1926
|
_removeAnnotation(id) {
|
|
1883
|
-
const annotation = this.
|
|
1927
|
+
const annotation = this.getAnnotation(id);
|
|
1884
1928
|
if (!annotation) return
|
|
1885
1929
|
|
|
1886
1930
|
this._removeAnnotationFromPage(id, annotation.page);
|
|
1887
|
-
this.annotations.delete(id);
|
|
1931
|
+
this.annotations.delete(this._key(id));
|
|
1888
1932
|
}
|
|
1889
1933
|
|
|
1890
1934
|
_removeAnnotationFromPage(id, pageNumber) {
|
|
1891
1935
|
const pageAnnotations = this.annotationsByPage.get(pageNumber);
|
|
1892
1936
|
if (pageAnnotations) {
|
|
1893
|
-
const index = pageAnnotations.findIndex(a => a.id === id);
|
|
1937
|
+
const index = pageAnnotations.findIndex(a => this._key(a.id) === this._key(id));
|
|
1894
1938
|
if (index !== -1) {
|
|
1895
1939
|
pageAnnotations.splice(index, 1);
|
|
1896
1940
|
}
|
|
@@ -2002,6 +2046,16 @@
|
|
|
2002
2046
|
this._triggerDownload(pdfBytes, filename);
|
|
2003
2047
|
}
|
|
2004
2048
|
|
|
2049
|
+
// Download the original file untouched. Used for encrypted documents, which
|
|
2050
|
+
// pdf-lib can't open to embed annotations or the watermark.
|
|
2051
|
+
async downloadOriginal() {
|
|
2052
|
+
const request = new request_js.FetchRequest("get", this.documentUrl, { responseKind: "blob" });
|
|
2053
|
+
const response = await request.perform();
|
|
2054
|
+
const pdfBytes = await response.response.arrayBuffer();
|
|
2055
|
+
const filename = this._sanitizeFilename(this.documentName || "document");
|
|
2056
|
+
this._triggerDownload(pdfBytes, filename);
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2005
2059
|
_applyWatermarkToPage(page, font, width, height) {
|
|
2006
2060
|
if (!this.userName) return
|
|
2007
2061
|
|
|
@@ -4383,7 +4437,7 @@
|
|
|
4383
4437
|
}
|
|
4384
4438
|
|
|
4385
4439
|
// Selection state
|
|
4386
|
-
if (this.selectedAnnotationId === annotation.id) {
|
|
4440
|
+
if (this.selectedAnnotationId === String(annotation.id)) {
|
|
4387
4441
|
item.classList.add("selected");
|
|
4388
4442
|
}
|
|
4389
4443
|
|
|
@@ -4491,6 +4545,9 @@
|
|
|
4491
4545
|
}
|
|
4492
4546
|
|
|
4493
4547
|
_selectItem(annotationId) {
|
|
4548
|
+
// Ids arrive as numbers from JSON payloads but as strings from DOM
|
|
4549
|
+
// datasets and deep-links, so selection state is tracked as string
|
|
4550
|
+
annotationId = String(annotationId);
|
|
4494
4551
|
const previousId = this.selectedAnnotationId;
|
|
4495
4552
|
|
|
4496
4553
|
// Skip if already selected
|
|
@@ -4547,7 +4604,7 @@
|
|
|
4547
4604
|
onAnnotationDeleted(annotation) {
|
|
4548
4605
|
if (this.isOpen) {
|
|
4549
4606
|
// Clear selection if deleted annotation was selected
|
|
4550
|
-
if (this.selectedAnnotationId === annotation.id) {
|
|
4607
|
+
if (this.selectedAnnotationId === String(annotation.id)) {
|
|
4551
4608
|
const previousId = this.selectedAnnotationId;
|
|
4552
4609
|
this.selectedAnnotationId = null;
|
|
4553
4610
|
this.element.dispatchEvent(new CustomEvent("pdf-sidebar:annotation-deselected", {
|
|
@@ -5619,6 +5676,122 @@
|
|
|
5619
5676
|
}
|
|
5620
5677
|
}
|
|
5621
5678
|
|
|
5679
|
+
/**
|
|
5680
|
+
* PasswordPrompt - Modal dialog for unlocking password-protected PDFs.
|
|
5681
|
+
*
|
|
5682
|
+
* Shown over the viewer when PDF.js requests a password during document
|
|
5683
|
+
* load. Each request() call returns a promise that resolves with the
|
|
5684
|
+
* entered password, or rejects when the user cancels.
|
|
5685
|
+
*/
|
|
5686
|
+
|
|
5687
|
+
class PasswordPrompt {
|
|
5688
|
+
constructor(options = {}) {
|
|
5689
|
+
this.container = options.container;
|
|
5690
|
+
|
|
5691
|
+
this.element = null;
|
|
5692
|
+
this.inputElement = null;
|
|
5693
|
+
this.errorElement = null;
|
|
5694
|
+
|
|
5695
|
+
this._pending = null; // { resolve, reject } for the in-flight request
|
|
5696
|
+
|
|
5697
|
+
this._createUI();
|
|
5698
|
+
this._setupEventListeners();
|
|
5699
|
+
}
|
|
5700
|
+
|
|
5701
|
+
_createUI() {
|
|
5702
|
+
this.element = document.createElement("div");
|
|
5703
|
+
this.element.className = "pdf-password-prompt hidden";
|
|
5704
|
+
this.element.innerHTML = `
|
|
5705
|
+
<div class="pdf-password-prompt-dialog" role="dialog" aria-modal="true" aria-labelledby="pdf-password-prompt-title">
|
|
5706
|
+
<h2 class="pdf-password-prompt-title" id="pdf-password-prompt-title">Password required</h2>
|
|
5707
|
+
<p class="pdf-password-prompt-message">This document is protected. Enter the password to open it.</p>
|
|
5708
|
+
<p class="pdf-password-prompt-error hidden">Incorrect password. Please try again.</p>
|
|
5709
|
+
<input type="password" class="pdf-password-prompt-input" autocomplete="off" aria-label="Document password">
|
|
5710
|
+
<div class="pdf-password-prompt-buttons">
|
|
5711
|
+
<button type="button" class="pdf-password-prompt-btn pdf-password-prompt-cancel">Cancel</button>
|
|
5712
|
+
<button type="button" class="pdf-password-prompt-btn pdf-password-prompt-submit">Open</button>
|
|
5713
|
+
</div>
|
|
5714
|
+
</div>
|
|
5715
|
+
`;
|
|
5716
|
+
|
|
5717
|
+
this.inputElement = this.element.querySelector(".pdf-password-prompt-input");
|
|
5718
|
+
this.errorElement = this.element.querySelector(".pdf-password-prompt-error");
|
|
5719
|
+
this.submitButton = this.element.querySelector(".pdf-password-prompt-submit");
|
|
5720
|
+
this.cancelButton = this.element.querySelector(".pdf-password-prompt-cancel");
|
|
5721
|
+
|
|
5722
|
+
this.container.appendChild(this.element);
|
|
5723
|
+
}
|
|
5724
|
+
|
|
5725
|
+
_setupEventListeners() {
|
|
5726
|
+
this.submitButton.addEventListener("click", () => this._submit());
|
|
5727
|
+
this.cancelButton.addEventListener("click", () => this._cancel());
|
|
5728
|
+
|
|
5729
|
+
this.element.addEventListener("keydown", (e) => {
|
|
5730
|
+
if (e.key === "Enter") {
|
|
5731
|
+
e.preventDefault();
|
|
5732
|
+
this._submit();
|
|
5733
|
+
} else if (e.key === "Escape") {
|
|
5734
|
+
e.preventDefault();
|
|
5735
|
+
this._cancel();
|
|
5736
|
+
}
|
|
5737
|
+
});
|
|
5738
|
+
}
|
|
5739
|
+
|
|
5740
|
+
/**
|
|
5741
|
+
* Ask the user for the document password.
|
|
5742
|
+
* @param {Object} options
|
|
5743
|
+
* @param {boolean} options.retry - Whether a previous attempt was incorrect
|
|
5744
|
+
* @returns {Promise<string>} Resolves with the password, rejects on cancel
|
|
5745
|
+
*/
|
|
5746
|
+
request({ retry = false } = {}) {
|
|
5747
|
+
return new Promise((resolve, reject) => {
|
|
5748
|
+
// A request should never overlap another, but if it does, cancel the old one
|
|
5749
|
+
this._pending?.reject(new Error("Password entry cancelled"));
|
|
5750
|
+
this._pending = { resolve, reject };
|
|
5751
|
+
|
|
5752
|
+
this.errorElement.classList.toggle("hidden", !retry);
|
|
5753
|
+
this.inputElement.value = "";
|
|
5754
|
+
this.element.classList.remove("hidden");
|
|
5755
|
+
this.inputElement.focus({ preventScroll: true });
|
|
5756
|
+
})
|
|
5757
|
+
}
|
|
5758
|
+
|
|
5759
|
+
_submit() {
|
|
5760
|
+
const password = this.inputElement.value;
|
|
5761
|
+
if (!password) {
|
|
5762
|
+
this.inputElement.focus({ preventScroll: true });
|
|
5763
|
+
return
|
|
5764
|
+
}
|
|
5765
|
+
|
|
5766
|
+
const pending = this._pending;
|
|
5767
|
+
this._pending = null;
|
|
5768
|
+
this._hide();
|
|
5769
|
+
pending?.resolve(password);
|
|
5770
|
+
}
|
|
5771
|
+
|
|
5772
|
+
_cancel() {
|
|
5773
|
+
const pending = this._pending;
|
|
5774
|
+
this._pending = null;
|
|
5775
|
+
this._hide();
|
|
5776
|
+
pending?.reject(new Error("Password entry cancelled"));
|
|
5777
|
+
}
|
|
5778
|
+
|
|
5779
|
+
_hide() {
|
|
5780
|
+
this.element.classList.add("hidden");
|
|
5781
|
+
this.inputElement.value = "";
|
|
5782
|
+
}
|
|
5783
|
+
|
|
5784
|
+
/**
|
|
5785
|
+
* Clean up. Safe to call multiple times.
|
|
5786
|
+
*/
|
|
5787
|
+
destroy() {
|
|
5788
|
+
this._pending?.reject(new Error("Password entry cancelled"));
|
|
5789
|
+
this._pending = null;
|
|
5790
|
+
this.element?.remove();
|
|
5791
|
+
this.element = null;
|
|
5792
|
+
}
|
|
5793
|
+
}
|
|
5794
|
+
|
|
5622
5795
|
class BaseTool {
|
|
5623
5796
|
constructor(pdfViewer) {
|
|
5624
5797
|
this.pdfViewer = pdfViewer;
|
|
@@ -7244,6 +7417,9 @@
|
|
|
7244
7417
|
|
|
7245
7418
|
this.currentTool = null;
|
|
7246
7419
|
this.currentMode = ToolMode.SELECT;
|
|
7420
|
+
// True once an encrypted document is loaded: annotations and annotated
|
|
7421
|
+
// download are disabled (pdf-lib can't open encrypted files)
|
|
7422
|
+
this.readOnly = false;
|
|
7247
7423
|
this.selectedAnnotation = null;
|
|
7248
7424
|
this.selectedAnnotationElement = null;
|
|
7249
7425
|
this.pendingAnnotationSelection = null; // Annotation ID to select when rendered
|
|
@@ -7285,9 +7461,13 @@
|
|
|
7285
7461
|
return
|
|
7286
7462
|
}
|
|
7287
7463
|
|
|
7464
|
+
// Modal shown when a document needs a password to open
|
|
7465
|
+
this.passwordPrompt = new PasswordPrompt({ container: this.container });
|
|
7466
|
+
|
|
7288
7467
|
// Core viewer (PDF.js wrapper with lazy rendering and events)
|
|
7289
7468
|
this.viewer = new CoreViewer(this.pagesContainer, {
|
|
7290
|
-
initialScale: 1.0
|
|
7469
|
+
initialScale: 1.0,
|
|
7470
|
+
onPasswordRequest: ({ retry }) => this.passwordPrompt.request({ retry })
|
|
7291
7471
|
});
|
|
7292
7472
|
|
|
7293
7473
|
// Subscribe to core viewer events
|
|
@@ -7431,9 +7611,11 @@
|
|
|
7431
7611
|
*/
|
|
7432
7612
|
_onDocumentLoaded(pageCount) {
|
|
7433
7613
|
this._currentPage = 1;
|
|
7614
|
+
this.readOnly = this.viewer.isEncrypted;
|
|
7434
7615
|
this._dispatchEvent("pdf-viewer:ready", {
|
|
7435
7616
|
pageCount,
|
|
7436
|
-
currentPage: 1
|
|
7617
|
+
currentPage: 1,
|
|
7618
|
+
readOnly: this.readOnly
|
|
7437
7619
|
});
|
|
7438
7620
|
}
|
|
7439
7621
|
|
|
@@ -7530,11 +7712,12 @@
|
|
|
7530
7712
|
await this.thumbnailSidebar.setDocument(this.viewer.pdfDocument);
|
|
7531
7713
|
}
|
|
7532
7714
|
|
|
7533
|
-
// Load existing annotations from store
|
|
7534
|
-
|
|
7535
|
-
|
|
7536
|
-
|
|
7537
|
-
|
|
7715
|
+
// Load existing annotations from store and render them on all rendered
|
|
7716
|
+
// pages. Skipped for encrypted documents, which are view-only.
|
|
7717
|
+
if (!this.readOnly) {
|
|
7718
|
+
await this.annotationManager.loadAnnotations();
|
|
7719
|
+
this._renderAnnotations();
|
|
7720
|
+
}
|
|
7538
7721
|
|
|
7539
7722
|
const annotations = this.annotationManager.getAllAnnotations();
|
|
7540
7723
|
this.container.dispatchEvent(new CustomEvent("pdf-viewer:annotations-loaded", {
|
|
@@ -7547,9 +7730,10 @@
|
|
|
7547
7730
|
this.viewer.goToPage(this.initialPage);
|
|
7548
7731
|
}
|
|
7549
7732
|
|
|
7550
|
-
// Navigate to initial annotation if specified
|
|
7733
|
+
// Navigate to initial annotation if specified, flashing it so
|
|
7734
|
+
// deep-link visitors can spot it immediately
|
|
7551
7735
|
if (this.initialAnnotation) {
|
|
7552
|
-
this.
|
|
7736
|
+
this._scrollToAnnotationWithFlash(this.initialAnnotation);
|
|
7553
7737
|
}
|
|
7554
7738
|
|
|
7555
7739
|
// Start with select tool
|
|
@@ -7564,6 +7748,11 @@
|
|
|
7564
7748
|
}
|
|
7565
7749
|
|
|
7566
7750
|
setTool(mode) {
|
|
7751
|
+
// Encrypted documents are view-only: only the select tool is allowed
|
|
7752
|
+
if (this.readOnly && mode !== ToolMode.SELECT) {
|
|
7753
|
+
return
|
|
7754
|
+
}
|
|
7755
|
+
|
|
7567
7756
|
// Deactivate current tool
|
|
7568
7757
|
if (this.currentTool) {
|
|
7569
7758
|
this.currentTool.deactivate();
|
|
@@ -8448,20 +8637,9 @@
|
|
|
8448
8637
|
}
|
|
8449
8638
|
}
|
|
8450
8639
|
|
|
8451
|
-
_scrollToAnnotation(annotationId) {
|
|
8452
|
-
const annotation = this.annotationManager.getAnnotation(annotationId);
|
|
8453
|
-
if (!annotation) return
|
|
8454
|
-
|
|
8455
|
-
// Mark this annotation for selection when it's rendered
|
|
8456
|
-
this.pendingAnnotationSelection = annotationId;
|
|
8457
|
-
|
|
8458
|
-
// Go to the page - the annotation will be selected in _renderAnnotationsForPage
|
|
8459
|
-
this.viewer.goToPage(annotation.page);
|
|
8460
|
-
}
|
|
8461
|
-
|
|
8462
8640
|
/**
|
|
8463
8641
|
* Scroll to annotation and flash/highlight it.
|
|
8464
|
-
* Called from the annotation sidebar
|
|
8642
|
+
* Called from the annotation sidebar and for initial deep-links.
|
|
8465
8643
|
*/
|
|
8466
8644
|
_scrollToAnnotationWithFlash(annotationId) {
|
|
8467
8645
|
const annotation = this.annotationManager.getAnnotation(annotationId);
|
|
@@ -8577,10 +8755,15 @@
|
|
|
8577
8755
|
});
|
|
8578
8756
|
}
|
|
8579
8757
|
|
|
8580
|
-
// Download with annotations
|
|
8758
|
+
// Download with annotations (or the original file for encrypted documents,
|
|
8759
|
+
// which pdf-lib can't open to embed annotations)
|
|
8581
8760
|
async download() {
|
|
8582
8761
|
try {
|
|
8583
|
-
|
|
8762
|
+
if (this.readOnly) {
|
|
8763
|
+
await this.downloadManager.downloadOriginal();
|
|
8764
|
+
} else {
|
|
8765
|
+
await this.downloadManager.downloadWithAnnotations();
|
|
8766
|
+
}
|
|
8584
8767
|
} catch (error) {
|
|
8585
8768
|
console.error("Failed to download PDF:", error);
|
|
8586
8769
|
throw error
|
|
@@ -8609,6 +8792,7 @@
|
|
|
8609
8792
|
this.findController?.destroy();
|
|
8610
8793
|
this.findBar?.destroy();
|
|
8611
8794
|
this.colorPicker?.destroy();
|
|
8795
|
+
this.passwordPrompt?.destroy();
|
|
8612
8796
|
|
|
8613
8797
|
Object.values(this.tools || {}).forEach(tool => tool.destroy?.());
|
|
8614
8798
|
|
|
@@ -8678,7 +8862,9 @@
|
|
|
8678
8862
|
if (this.hasLoadingOverlayTarget) {
|
|
8679
8863
|
this.loadingOverlayTarget.classList.add("hidden");
|
|
8680
8864
|
}
|
|
8681
|
-
const
|
|
8865
|
+
const isPasswordError = /password/i.test(String(error?.message || "")) || /password/i.test(String(error?.name || ""));
|
|
8866
|
+
const message = this.errorMessageValue ||
|
|
8867
|
+
(isPasswordError ? "A password is required to open this document" : "Failed to load PDF document");
|
|
8682
8868
|
this._showError(message);
|
|
8683
8869
|
this.containerTarget.dispatchEvent(new CustomEvent("pdf-viewer:load-failed", {
|
|
8684
8870
|
bubbles: true,
|
|
@@ -8859,6 +9045,10 @@
|
|
|
8859
9045
|
}
|
|
8860
9046
|
|
|
8861
9047
|
_activateTool(toolName) {
|
|
9048
|
+
// View-only for encrypted documents; the buttons are disabled, but guard
|
|
9049
|
+
// against keyboard/programmatic activation too
|
|
9050
|
+
if (this._readOnly && toolName !== "select") return
|
|
9051
|
+
|
|
8862
9052
|
// Tool map for name -> mode conversion
|
|
8863
9053
|
const toolMap = {
|
|
8864
9054
|
select: ToolMode.SELECT,
|
|
@@ -9044,8 +9234,8 @@
|
|
|
9044
9234
|
_setupPageNavigationListeners() {
|
|
9045
9235
|
// Listen for ready event from PdfViewer
|
|
9046
9236
|
this._readyHandler = (e) => {
|
|
9047
|
-
const { pageCount, currentPage } = e.detail;
|
|
9048
|
-
this._onViewerReady(pageCount, currentPage);
|
|
9237
|
+
const { pageCount, currentPage, readOnly } = e.detail;
|
|
9238
|
+
this._onViewerReady(pageCount, currentPage, readOnly);
|
|
9049
9239
|
};
|
|
9050
9240
|
this.containerTarget.addEventListener("pdf-viewer:ready", this._readyHandler);
|
|
9051
9241
|
|
|
@@ -9057,12 +9247,16 @@
|
|
|
9057
9247
|
this.containerTarget.addEventListener("pdf-viewer:page-changed", this._pageChangedHandler);
|
|
9058
9248
|
}
|
|
9059
9249
|
|
|
9060
|
-
_onViewerReady(pageCount, currentPage) {
|
|
9250
|
+
_onViewerReady(pageCount, currentPage, readOnly) {
|
|
9061
9251
|
// Hide the loading overlay
|
|
9062
9252
|
if (this.hasLoadingOverlayTarget) {
|
|
9063
9253
|
this.loadingOverlayTarget.classList.add("hidden");
|
|
9064
9254
|
}
|
|
9065
9255
|
|
|
9256
|
+
if (readOnly) {
|
|
9257
|
+
this._enterReadOnlyMode();
|
|
9258
|
+
}
|
|
9259
|
+
|
|
9066
9260
|
if (this.hasPageCountTarget) {
|
|
9067
9261
|
this.pageCountTarget.textContent = pageCount;
|
|
9068
9262
|
}
|
|
@@ -9083,6 +9277,16 @@
|
|
|
9083
9277
|
this._setZoomPreset("auto");
|
|
9084
9278
|
}
|
|
9085
9279
|
|
|
9280
|
+
// Encrypted documents are view-only: disable the annotation tools and hide
|
|
9281
|
+
// the color picker. Download stays enabled but delivers the original file.
|
|
9282
|
+
_enterReadOnlyMode() {
|
|
9283
|
+
this._readOnly = true;
|
|
9284
|
+
this.containerTarget.classList.add("pdf-viewer-read-only");
|
|
9285
|
+
this.containerTarget
|
|
9286
|
+
.querySelectorAll('.pdf-tool-btn[data-tool]:not([data-tool="select"]), .pdf-overflow-tool-btn[data-tool]:not([data-tool="select"])')
|
|
9287
|
+
.forEach(btn => { btn.disabled = true; });
|
|
9288
|
+
}
|
|
9289
|
+
|
|
9086
9290
|
_onPageChanged(currentPage, pageCount) {
|
|
9087
9291
|
if (this.hasPageInputTarget && document.activeElement !== this.pageInputTarget) {
|
|
9088
9292
|
this.pageInputTarget.value = currentPage;
|
|
@@ -2467,6 +2467,112 @@
|
|
|
2467
2467
|
}
|
|
2468
2468
|
}
|
|
2469
2469
|
|
|
2470
|
+
// Password prompt for encrypted documents.
|
|
2471
|
+
// Sits above the loading overlay (z-index 100) and toasts (1001) so it is
|
|
2472
|
+
// reachable while the host app's spinner is still up.
|
|
2473
|
+
.pdf-password-prompt {
|
|
2474
|
+
position: absolute;
|
|
2475
|
+
top: 0;
|
|
2476
|
+
left: 0;
|
|
2477
|
+
right: 0;
|
|
2478
|
+
bottom: 0;
|
|
2479
|
+
display: flex;
|
|
2480
|
+
align-items: center;
|
|
2481
|
+
justify-content: center;
|
|
2482
|
+
background: rgba(0, 0, 0, 0.4);
|
|
2483
|
+
z-index: 1002;
|
|
2484
|
+
|
|
2485
|
+
&.hidden {
|
|
2486
|
+
display: none;
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
.pdf-password-prompt-dialog {
|
|
2491
|
+
width: min(360px, calc(100% - 32px));
|
|
2492
|
+
padding: 24px;
|
|
2493
|
+
background: #fff;
|
|
2494
|
+
border-radius: 8px;
|
|
2495
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
.pdf-password-prompt-title {
|
|
2499
|
+
margin: 0 0 8px;
|
|
2500
|
+
font-size: 16px;
|
|
2501
|
+
font-weight: 600;
|
|
2502
|
+
color: #333;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
.pdf-password-prompt-message {
|
|
2506
|
+
margin: 0 0 12px;
|
|
2507
|
+
font-size: 14px;
|
|
2508
|
+
color: #666;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
.pdf-password-prompt-error {
|
|
2512
|
+
margin: 0 0 12px;
|
|
2513
|
+
font-size: 13px;
|
|
2514
|
+
color: #c62828;
|
|
2515
|
+
|
|
2516
|
+
&.hidden {
|
|
2517
|
+
display: none;
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
.pdf-password-prompt-input {
|
|
2522
|
+
width: 100%;
|
|
2523
|
+
box-sizing: border-box;
|
|
2524
|
+
padding: 8px 10px;
|
|
2525
|
+
border: 1px solid #ccc;
|
|
2526
|
+
border-radius: 6px;
|
|
2527
|
+
font-size: 14px;
|
|
2528
|
+
|
|
2529
|
+
&:focus {
|
|
2530
|
+
outline: none;
|
|
2531
|
+
border-color: #0060df;
|
|
2532
|
+
box-shadow: 0 0 0 2px rgba(0, 96, 223, 0.2);
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
.pdf-password-prompt-buttons {
|
|
2537
|
+
display: flex;
|
|
2538
|
+
justify-content: flex-end;
|
|
2539
|
+
gap: 8px;
|
|
2540
|
+
margin-top: 16px;
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
.pdf-password-prompt-btn {
|
|
2544
|
+
padding: 6px 16px;
|
|
2545
|
+
border: 1px solid #ccc;
|
|
2546
|
+
border-radius: 6px;
|
|
2547
|
+
background: #fff;
|
|
2548
|
+
color: #333;
|
|
2549
|
+
font-size: 13px;
|
|
2550
|
+
font-weight: 500;
|
|
2551
|
+
cursor: pointer;
|
|
2552
|
+
|
|
2553
|
+
&:hover {
|
|
2554
|
+
background: #f5f5f5;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
&.pdf-password-prompt-submit {
|
|
2558
|
+
background: #0060df;
|
|
2559
|
+
border-color: #0060df;
|
|
2560
|
+
color: #fff;
|
|
2561
|
+
|
|
2562
|
+
&:hover {
|
|
2563
|
+
background: #0050bf;
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
// Read-only mode for encrypted documents: annotation tool buttons are
|
|
2569
|
+
// disabled in JS; the color picker has no use, so hide it entirely.
|
|
2570
|
+
.pdf-viewer-read-only {
|
|
2571
|
+
.pdf-toolbar-colors {
|
|
2572
|
+
display: none;
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2470
2576
|
// Screen reader only class for aria-live regions
|
|
2471
2577
|
// Visually hidden but accessible to screen readers
|
|
2472
2578
|
.pdf-viewer-announcer {
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: stimulus-pdf-viewer-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jeremy Baker
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: railties
|