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: 863e0c885d62300048eda7098bb47e91082b609197aa5712557f9e76c1a33cd2
4
- data.tar.gz: c99cdaa21cd6318fdec59037bd07ec86d1aed8260f45bd17852c90305e903f77
3
+ metadata.gz: 838e9d480a11d8bbf5a590bad2ab618624c1ec95284a1b7aa28b2c40468447ab
4
+ data.tar.gz: 7296bfffca896a43fb16a50a3d271a790eab5541d3744aef8df90ae336e2767a
5
5
  SHA512:
6
- metadata.gz: 0a203691d3cc984e25cae915193472832086a52671137d9ccedf12dd8712033a99d3505c4cb664c5f5eb76943591bbb6f868703fbdf7b75ae3de5acd5957244d
7
- data.tar.gz: 0bf609b800a496af6828e6382608214a75d6c7d2c583c3a34773797236fe73a7d66073369f28891908477fd6b02c40a19a7765b22d72f47f0d5baf1106087527
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
- return await this._loadDocument({ data })
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.annotations.get(id);
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.annotations.get(annotation.id);
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.annotations.get(id);
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
- await this.annotationManager.loadAnnotations();
7515
-
7516
- // Render annotations on all rendered pages
7517
- this._renderAnnotations();
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._scrollToAnnotation(this.initialAnnotation);
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 when clicking an annotation.
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
- await this.downloadManager.downloadWithAnnotations();
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 message = this.errorMessageValue || "Failed to load PDF document";
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
- return await this._loadDocument({ data })
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.annotations.get(id);
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.annotations.get(annotation.id);
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.annotations.get(id);
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
- await this.annotationManager.loadAnnotations();
7535
-
7536
- // Render annotations on all rendered pages
7537
- this._renderAnnotations();
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._scrollToAnnotation(this.initialAnnotation);
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 when clicking an annotation.
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
- await this.downloadManager.downloadWithAnnotations();
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 message = this.errorMessageValue || "Failed to load PDF document";
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 {
@@ -1,7 +1,7 @@
1
1
  module StimulusPdfViewer
2
2
  module Rails
3
- VERSION = "0.4.0"
3
+ VERSION = '0.5.0'
4
4
  # This should match the npm package version
5
- STIMULUS_PDF_VIEWER_VERSION = "0.4.0"
5
+ STIMULUS_PDF_VIEWER_VERSION = "0.5.0"
6
6
  end
7
7
  end
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.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-06-24 00:00:00.000000000 Z
11
+ date: 2026-08-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties