stimulus-pdf-viewer-rails 0.4.1 → 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: 2304be55407cf2f6bda31771ad7dff2bd54499278bb24b24e0429f161c835569
4
- data.tar.gz: 28149508a79eaabd4b5f65120cb6758082b263e5b66540b693671e3a328d1424
3
+ metadata.gz: 838e9d480a11d8bbf5a590bad2ab618624c1ec95284a1b7aa28b2c40468447ab
4
+ data.tar.gz: 7296bfffca896a43fb16a50a3d271a790eab5541d3744aef8df90ae336e2767a
5
5
  SHA512:
6
- metadata.gz: 86c15ca518c858a764d8050175314e96244dd6941dd1b9556d58758e33f8e1767968516ca617e8a3dd9d13752caafbd6c2d1fb429658ba8981f7f730a8ec24d8
7
- data.tar.gz: 757a7972ddabe9b0f12c737dda5336cd1be4f0a319faa73df52887e08bde64de84871cb1d22fce37f8fc571b3a648b1fa229bec9d1c29c3ae6f7f3a0d0a1973d
6
+ metadata.gz: 32804a93259414ccee6b961564c6587204c6deadcbf5bf07de69d1872751eb125c89099ec5e69125934f7a4ca85974b9bdf5b2b620912725bccb599ce27e082e
7
+ data.tar.gz: '099ea9e6b131bfba5f48b4a63818c03841e05b5da782f51adca84418bc9ee3538b8cc47982e169e76386acaa7c125278bb3794f53ac15a021efd152c1bbf9c37'
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
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
+
5
19
  ## [0.4.1] - 2026-08-10
6
20
 
7
21
  ### Fixed
@@ -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
 
@@ -1988,6 +2026,16 @@ class DownloadManager {
1988
2026
  this._triggerDownload(pdfBytes, filename);
1989
2027
  }
1990
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
+
1991
2039
  _applyWatermarkToPage(page, font, width, height) {
1992
2040
  if (!this.userName) return
1993
2041
 
@@ -5608,6 +5656,122 @@ class FindBar {
5608
5656
  }
5609
5657
  }
5610
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
+
5611
5775
  class BaseTool {
5612
5776
  constructor(pdfViewer) {
5613
5777
  this.pdfViewer = pdfViewer;
@@ -7233,6 +7397,9 @@ class PdfViewer {
7233
7397
 
7234
7398
  this.currentTool = null;
7235
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;
7236
7403
  this.selectedAnnotation = null;
7237
7404
  this.selectedAnnotationElement = null;
7238
7405
  this.pendingAnnotationSelection = null; // Annotation ID to select when rendered
@@ -7274,9 +7441,13 @@ class PdfViewer {
7274
7441
  return
7275
7442
  }
7276
7443
 
7444
+ // Modal shown when a document needs a password to open
7445
+ this.passwordPrompt = new PasswordPrompt({ container: this.container });
7446
+
7277
7447
  // Core viewer (PDF.js wrapper with lazy rendering and events)
7278
7448
  this.viewer = new CoreViewer(this.pagesContainer, {
7279
- initialScale: 1.0
7449
+ initialScale: 1.0,
7450
+ onPasswordRequest: ({ retry }) => this.passwordPrompt.request({ retry })
7280
7451
  });
7281
7452
 
7282
7453
  // Subscribe to core viewer events
@@ -7420,9 +7591,11 @@ class PdfViewer {
7420
7591
  */
7421
7592
  _onDocumentLoaded(pageCount) {
7422
7593
  this._currentPage = 1;
7594
+ this.readOnly = this.viewer.isEncrypted;
7423
7595
  this._dispatchEvent("pdf-viewer:ready", {
7424
7596
  pageCount,
7425
- currentPage: 1
7597
+ currentPage: 1,
7598
+ readOnly: this.readOnly
7426
7599
  });
7427
7600
  }
7428
7601
 
@@ -7519,11 +7692,12 @@ class PdfViewer {
7519
7692
  await this.thumbnailSidebar.setDocument(this.viewer.pdfDocument);
7520
7693
  }
7521
7694
 
7522
- // Load existing annotations from store
7523
- await this.annotationManager.loadAnnotations();
7524
-
7525
- // Render annotations on all rendered pages
7526
- 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
+ }
7527
7701
 
7528
7702
  const annotations = this.annotationManager.getAllAnnotations();
7529
7703
  this.container.dispatchEvent(new CustomEvent("pdf-viewer:annotations-loaded", {
@@ -7554,6 +7728,11 @@ class PdfViewer {
7554
7728
  }
7555
7729
 
7556
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
+
7557
7736
  // Deactivate current tool
7558
7737
  if (this.currentTool) {
7559
7738
  this.currentTool.deactivate();
@@ -8556,10 +8735,15 @@ class PdfViewer {
8556
8735
  });
8557
8736
  }
8558
8737
 
8559
- // Download with annotations
8738
+ // Download with annotations (or the original file for encrypted documents,
8739
+ // which pdf-lib can't open to embed annotations)
8560
8740
  async download() {
8561
8741
  try {
8562
- await this.downloadManager.downloadWithAnnotations();
8742
+ if (this.readOnly) {
8743
+ await this.downloadManager.downloadOriginal();
8744
+ } else {
8745
+ await this.downloadManager.downloadWithAnnotations();
8746
+ }
8563
8747
  } catch (error) {
8564
8748
  console.error("Failed to download PDF:", error);
8565
8749
  throw error
@@ -8588,6 +8772,7 @@ class PdfViewer {
8588
8772
  this.findController?.destroy();
8589
8773
  this.findBar?.destroy();
8590
8774
  this.colorPicker?.destroy();
8775
+ this.passwordPrompt?.destroy();
8591
8776
 
8592
8777
  Object.values(this.tools || {}).forEach(tool => tool.destroy?.());
8593
8778
 
@@ -8657,7 +8842,9 @@ class pdf_viewer_controller extends Controller {
8657
8842
  if (this.hasLoadingOverlayTarget) {
8658
8843
  this.loadingOverlayTarget.classList.add("hidden");
8659
8844
  }
8660
- 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");
8661
8848
  this._showError(message);
8662
8849
  this.containerTarget.dispatchEvent(new CustomEvent("pdf-viewer:load-failed", {
8663
8850
  bubbles: true,
@@ -8838,6 +9025,10 @@ class pdf_viewer_controller extends Controller {
8838
9025
  }
8839
9026
 
8840
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
+
8841
9032
  // Tool map for name -> mode conversion
8842
9033
  const toolMap = {
8843
9034
  select: ToolMode.SELECT,
@@ -9023,8 +9214,8 @@ class pdf_viewer_controller extends Controller {
9023
9214
  _setupPageNavigationListeners() {
9024
9215
  // Listen for ready event from PdfViewer
9025
9216
  this._readyHandler = (e) => {
9026
- const { pageCount, currentPage } = e.detail;
9027
- this._onViewerReady(pageCount, currentPage);
9217
+ const { pageCount, currentPage, readOnly } = e.detail;
9218
+ this._onViewerReady(pageCount, currentPage, readOnly);
9028
9219
  };
9029
9220
  this.containerTarget.addEventListener("pdf-viewer:ready", this._readyHandler);
9030
9221
 
@@ -9036,12 +9227,16 @@ class pdf_viewer_controller extends Controller {
9036
9227
  this.containerTarget.addEventListener("pdf-viewer:page-changed", this._pageChangedHandler);
9037
9228
  }
9038
9229
 
9039
- _onViewerReady(pageCount, currentPage) {
9230
+ _onViewerReady(pageCount, currentPage, readOnly) {
9040
9231
  // Hide the loading overlay
9041
9232
  if (this.hasLoadingOverlayTarget) {
9042
9233
  this.loadingOverlayTarget.classList.add("hidden");
9043
9234
  }
9044
9235
 
9236
+ if (readOnly) {
9237
+ this._enterReadOnlyMode();
9238
+ }
9239
+
9045
9240
  if (this.hasPageCountTarget) {
9046
9241
  this.pageCountTarget.textContent = pageCount;
9047
9242
  }
@@ -9062,6 +9257,16 @@ class pdf_viewer_controller extends Controller {
9062
9257
  this._setZoomPreset("auto");
9063
9258
  }
9064
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
+
9065
9270
  _onPageChanged(currentPage, pageCount) {
9066
9271
  if (this.hasPageInputTarget && document.activeElement !== this.pageInputTarget) {
9067
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
 
@@ -2008,6 +2046,16 @@
2008
2046
  this._triggerDownload(pdfBytes, filename);
2009
2047
  }
2010
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
+
2011
2059
  _applyWatermarkToPage(page, font, width, height) {
2012
2060
  if (!this.userName) return
2013
2061
 
@@ -5628,6 +5676,122 @@
5628
5676
  }
5629
5677
  }
5630
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
+
5631
5795
  class BaseTool {
5632
5796
  constructor(pdfViewer) {
5633
5797
  this.pdfViewer = pdfViewer;
@@ -7253,6 +7417,9 @@
7253
7417
 
7254
7418
  this.currentTool = null;
7255
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;
7256
7423
  this.selectedAnnotation = null;
7257
7424
  this.selectedAnnotationElement = null;
7258
7425
  this.pendingAnnotationSelection = null; // Annotation ID to select when rendered
@@ -7294,9 +7461,13 @@
7294
7461
  return
7295
7462
  }
7296
7463
 
7464
+ // Modal shown when a document needs a password to open
7465
+ this.passwordPrompt = new PasswordPrompt({ container: this.container });
7466
+
7297
7467
  // Core viewer (PDF.js wrapper with lazy rendering and events)
7298
7468
  this.viewer = new CoreViewer(this.pagesContainer, {
7299
- initialScale: 1.0
7469
+ initialScale: 1.0,
7470
+ onPasswordRequest: ({ retry }) => this.passwordPrompt.request({ retry })
7300
7471
  });
7301
7472
 
7302
7473
  // Subscribe to core viewer events
@@ -7440,9 +7611,11 @@
7440
7611
  */
7441
7612
  _onDocumentLoaded(pageCount) {
7442
7613
  this._currentPage = 1;
7614
+ this.readOnly = this.viewer.isEncrypted;
7443
7615
  this._dispatchEvent("pdf-viewer:ready", {
7444
7616
  pageCount,
7445
- currentPage: 1
7617
+ currentPage: 1,
7618
+ readOnly: this.readOnly
7446
7619
  });
7447
7620
  }
7448
7621
 
@@ -7539,11 +7712,12 @@
7539
7712
  await this.thumbnailSidebar.setDocument(this.viewer.pdfDocument);
7540
7713
  }
7541
7714
 
7542
- // Load existing annotations from store
7543
- await this.annotationManager.loadAnnotations();
7544
-
7545
- // Render annotations on all rendered pages
7546
- 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
+ }
7547
7721
 
7548
7722
  const annotations = this.annotationManager.getAllAnnotations();
7549
7723
  this.container.dispatchEvent(new CustomEvent("pdf-viewer:annotations-loaded", {
@@ -7574,6 +7748,11 @@
7574
7748
  }
7575
7749
 
7576
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
+
7577
7756
  // Deactivate current tool
7578
7757
  if (this.currentTool) {
7579
7758
  this.currentTool.deactivate();
@@ -8576,10 +8755,15 @@
8576
8755
  });
8577
8756
  }
8578
8757
 
8579
- // Download with annotations
8758
+ // Download with annotations (or the original file for encrypted documents,
8759
+ // which pdf-lib can't open to embed annotations)
8580
8760
  async download() {
8581
8761
  try {
8582
- await this.downloadManager.downloadWithAnnotations();
8762
+ if (this.readOnly) {
8763
+ await this.downloadManager.downloadOriginal();
8764
+ } else {
8765
+ await this.downloadManager.downloadWithAnnotations();
8766
+ }
8583
8767
  } catch (error) {
8584
8768
  console.error("Failed to download PDF:", error);
8585
8769
  throw error
@@ -8608,6 +8792,7 @@
8608
8792
  this.findController?.destroy();
8609
8793
  this.findBar?.destroy();
8610
8794
  this.colorPicker?.destroy();
8795
+ this.passwordPrompt?.destroy();
8611
8796
 
8612
8797
  Object.values(this.tools || {}).forEach(tool => tool.destroy?.());
8613
8798
 
@@ -8677,7 +8862,9 @@
8677
8862
  if (this.hasLoadingOverlayTarget) {
8678
8863
  this.loadingOverlayTarget.classList.add("hidden");
8679
8864
  }
8680
- 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");
8681
8868
  this._showError(message);
8682
8869
  this.containerTarget.dispatchEvent(new CustomEvent("pdf-viewer:load-failed", {
8683
8870
  bubbles: true,
@@ -8858,6 +9045,10 @@
8858
9045
  }
8859
9046
 
8860
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
+
8861
9052
  // Tool map for name -> mode conversion
8862
9053
  const toolMap = {
8863
9054
  select: ToolMode.SELECT,
@@ -9043,8 +9234,8 @@
9043
9234
  _setupPageNavigationListeners() {
9044
9235
  // Listen for ready event from PdfViewer
9045
9236
  this._readyHandler = (e) => {
9046
- const { pageCount, currentPage } = e.detail;
9047
- this._onViewerReady(pageCount, currentPage);
9237
+ const { pageCount, currentPage, readOnly } = e.detail;
9238
+ this._onViewerReady(pageCount, currentPage, readOnly);
9048
9239
  };
9049
9240
  this.containerTarget.addEventListener("pdf-viewer:ready", this._readyHandler);
9050
9241
 
@@ -9056,12 +9247,16 @@
9056
9247
  this.containerTarget.addEventListener("pdf-viewer:page-changed", this._pageChangedHandler);
9057
9248
  }
9058
9249
 
9059
- _onViewerReady(pageCount, currentPage) {
9250
+ _onViewerReady(pageCount, currentPage, readOnly) {
9060
9251
  // Hide the loading overlay
9061
9252
  if (this.hasLoadingOverlayTarget) {
9062
9253
  this.loadingOverlayTarget.classList.add("hidden");
9063
9254
  }
9064
9255
 
9256
+ if (readOnly) {
9257
+ this._enterReadOnlyMode();
9258
+ }
9259
+
9065
9260
  if (this.hasPageCountTarget) {
9066
9261
  this.pageCountTarget.textContent = pageCount;
9067
9262
  }
@@ -9082,6 +9277,16 @@
9082
9277
  this._setZoomPreset("auto");
9083
9278
  }
9084
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
+
9085
9290
  _onPageChanged(currentPage, pageCount) {
9086
9291
  if (this.hasPageInputTarget && document.activeElement !== this.pageInputTarget) {
9087
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.1'
3
+ VERSION = '0.5.0'
4
4
  # This should match the npm package version
5
- STIMULUS_PDF_VIEWER_VERSION = '0.4.1'
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.1
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-08-10 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