]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Enhancement: pngx pdf viewer (#12043)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Mon, 9 Feb 2026 05:24:43 +0000 (21:24 -0800)
committerGitHub <noreply@github.com>
Mon, 9 Feb 2026 05:24:43 +0000 (21:24 -0800)
29 files changed:
src-ui/angular.json
src-ui/e2e/document-detail/document-detail.spec.ts
src-ui/jest.config.js
src-ui/package.json
src-ui/pnpm-lock.yaml
src-ui/setup-jest.ts
src-ui/src/app/components/admin/settings/settings.component.html
src-ui/src/app/components/admin/settings/settings.component.ts
src-ui/src/app/components/common/pdf-editor/pdf-editor.component.html
src-ui/src/app/components/common/pdf-editor/pdf-editor.component.scss
src-ui/src/app/components/common/pdf-editor/pdf-editor.component.ts
src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.html [new file with mode: 0644]
src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.scss [new file with mode: 0644]
src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.ts [new file with mode: 0644]
src-ui/src/app/components/common/pdf-viewer/pdf-viewer.types.ts [new file with mode: 0644]
src-ui/src/app/components/common/preview-popup/preview-popup.component.html
src-ui/src/app/components/common/preview-popup/preview-popup.component.spec.ts
src-ui/src/app/components/common/preview-popup/preview-popup.component.ts
src-ui/src/app/components/document-detail/document-detail.component.html
src-ui/src/app/components/document-detail/document-detail.component.scss
src-ui/src/app/components/document-detail/document-detail.component.spec.ts
src-ui/src/app/components/document-detail/document-detail.component.ts
src-ui/src/app/components/document-detail/zoom-setting.ts [deleted file]
src-ui/src/app/data/ui-settings.ts
src-ui/src/main.ts
src-ui/src/test/mocks/pdfjs-legacy-build-pdf.ts [new file with mode: 0644]
src-ui/src/test/mocks/pdfjs-web-pdf_viewer.ts [new file with mode: 0644]
src-ui/tsconfig.spec.json

index 9d603978901a76e6e243d6370adca5567e246661..9ed7488b6666ea7c7ec34b1bcb3631fd1f5853e5 100644 (file)
@@ -86,7 +86,6 @@
             ],
             "scripts": [],
             "allowedCommonJsDependencies": [
-              "ng2-pdf-viewer",
               "file-saver",
               "utif"
             ],
index 8e09671880eb87b955e1d344a65757a4d8c63113..ba10745ecf63cd9cfa9a503469c4ef82d9492a4d 100644 (file)
@@ -72,7 +72,7 @@ test('should show a mobile preview', async ({ page }) => {
   await page.setViewportSize({ width: 400, height: 1000 })
   await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
   await page.getByRole('tab', { name: 'Preview' }).click()
-  await page.waitForSelector('pdf-viewer')
+  await page.waitForSelector('pngx-pdf-viewer')
 })
 
 test('should show a list of notes', async ({ page }) => {
index 03461c92ec92fe396e07e1c353e3c322878fddfd..7b06016dd6d96b30d3ac829ac64c6697a711c0e8 100644 (file)
@@ -31,6 +31,10 @@ module.exports = {
   moduleNameMapper: {
     ...esmPreset.moduleNameMapper,
     '^src/(.*)': '<rootDir>/src/$1',
+    '^pdfjs-dist/legacy/build/pdf\\.mjs$':
+      '<rootDir>/src/test/mocks/pdfjs-legacy-build-pdf.ts',
+    '^pdfjs-dist/web/pdf_viewer\\.mjs$':
+      '<rootDir>/src/test/mocks/pdfjs-web-pdf_viewer.ts',
   },
   workerIdleMemoryLimit: '512MB',
   reporters: [
index f37f8cc8d9538cb8b94fb8d4f304470e112790ad..e671eb61cbc0c642bf5aefa6bd578954a67c2b77 100644 (file)
     "bootstrap": "^5.3.8",
     "file-saver": "^2.0.5",
     "mime-names": "^1.0.0",
-    "ng2-pdf-viewer": "^10.4.0",
     "ngx-bootstrap-icons": "^1.9.3",
     "ngx-color": "^10.1.0",
     "ngx-cookie-service": "^21.1.0",
     "ngx-device-detector": "^11.0.0",
     "ngx-ui-tour-ng-bootstrap": "^18.0.0",
+    "pdfjs-dist": "^5.4.624",
     "rxjs": "^7.8.2",
     "tslib": "^2.8.1",
     "utif": "^3.1.0",
index 8645e2f750518f0c2a7d20f45a0614607f9aa275..f09f7b366ec964f6d648a2a415ae07cd38018dd1 100644 (file)
@@ -56,9 +56,6 @@ importers:
       mime-names:
         specifier: ^1.0.0
         version: 1.0.0
-      ng2-pdf-viewer:
-        specifier: ^10.4.0
-        version: 10.4.0
       ngx-bootstrap-icons:
         specifier: ^1.9.3
         version: 1.9.3(@angular/common@21.1.3(@angular/core@21.1.3(@angular/compiler@21.1.3)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@21.1.3(@angular/compiler@21.1.3)(rxjs@7.8.2)(zone.js@0.16.0))
@@ -74,6 +71,9 @@ importers:
       ngx-ui-tour-ng-bootstrap:
         specifier: ^18.0.0
         version: 18.0.0(2a89effa12f6df8cde064aa7713e7e29)
+      pdfjs-dist:
+        specifier: ^5.4.624
+        version: 5.4.624
       rxjs:
         specifier: ^7.8.2
         version: 7.8.2
@@ -2130,6 +2130,76 @@ packages:
     cpu: [x64]
     os: [win32]
 
+  '@napi-rs/canvas-android-arm64@0.1.90':
+    resolution: {integrity: sha512-3JBULVF+BIgr7yy7Rf8UjfbkfFx4CtXrkJFD1MDgKJ83b56o0U9ciT8ZGTCNmwWkzu8RbNKlyqPP3KYRG88y7Q==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [android]
+
+  '@napi-rs/canvas-darwin-arm64@0.1.90':
+    resolution: {integrity: sha512-L8XVTXl+8vd8u7nPqcX77NyG5RuFdVsJapQrKV9WE3jBayq1aSMht/IH7Dwiz/RNJ86E5ZSg9pyUPFIlx52PZA==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@napi-rs/canvas-darwin-x64@0.1.90':
+    resolution: {integrity: sha512-h0ukhlnGhacbn798VWYTQZpf6JPDzQYaow+vtQ2Fat7j7ImDdpg6tfeqvOTO1r8wS+s+VhBIFITC7aA1Aik0ZQ==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@napi-rs/canvas-linux-arm-gnueabihf@0.1.90':
+    resolution: {integrity: sha512-JCvTl99b/RfdBtgftqrf+5UNF7GIbp7c5YBFZ+Bd6++4Y3phaXG/4vD9ZcF1bw1P4VpALagHmxvodHuQ9/TfTg==}
+    engines: {node: '>= 10'}
+    cpu: [arm]
+    os: [linux]
+
+  '@napi-rs/canvas-linux-arm64-gnu@0.1.90':
+    resolution: {integrity: sha512-vbWFp8lrP8NIM5L4zNOwnsqKIkJo0+GIRUDcLFV9XEJCptCc1FY6/tM02PT7GN4PBgochUPB1nBHdji6q3ieyQ==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@napi-rs/canvas-linux-arm64-musl@0.1.90':
+    resolution: {integrity: sha512-8Bc0BgGEeOaux4EfIfNzcRRw0JE+lO9v6RWQFCJNM9dJFE4QJffTf88hnmbOaI6TEMpgWOKipbha3dpIdUqb/g==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@napi-rs/canvas-linux-riscv64-gnu@0.1.90':
+    resolution: {integrity: sha512-0iiVDG5IH+gJb/YUrY/pRdbsjcgvwUmeckL/0gShWAA7004ygX2ST69M1wcfyxXrzFYjdF8S/Sn6aCAeBi89XQ==}
+    engines: {node: '>= 10'}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@napi-rs/canvas-linux-x64-gnu@0.1.90':
+    resolution: {integrity: sha512-SkKmlHMvA5spXuKfh7p6TsScDf7lp5XlMbiUhjdCtWdOS6Qke/A4qGVOciy6piIUCJibL+YX+IgdGqzm2Mpx/w==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [linux]
+
+  '@napi-rs/canvas-linux-x64-musl@0.1.90':
+    resolution: {integrity: sha512-o6QgS10gAS4vvELGDOOWYfmERXtkVRYFWBCjomILWfMgCvBVutn8M97fsMW5CrEuJI8YuxuJ7U+/DQ9oG93vDA==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [linux]
+
+  '@napi-rs/canvas-win32-arm64-msvc@0.1.90':
+    resolution: {integrity: sha512-2UHO/DC1oyuSjeCAhHA0bTD9qsg58kknRqjJqRfvIEFtdqdtNTcWXMCT9rQCuJ8Yx5ldhyh2SSp7+UDqD2tXZQ==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@napi-rs/canvas-win32-x64-msvc@0.1.90':
+    resolution: {integrity: sha512-48CxEbzua5BP4+OumSZdi3+9fNiRO8cGNBlO2bKwx1PoyD1R2AXzPtqd/no1f1uSl0W2+ihOO1v3pqT3USbmgQ==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [win32]
+
+  '@napi-rs/canvas@0.1.90':
+    resolution: {integrity: sha512-vO9j7TfwF9qYCoTOPO39yPLreTRslBVOaeIwhDZkizDvBb0MounnTl0yeWUMBxP4Pnkg9Sv+3eQwpxNUmTwt0w==}
+    engines: {node: '>= 10'}
+
   '@napi-rs/nice-android-arm-eabi@1.1.1':
     resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==}
     engines: {node: '>= 10'}
@@ -5015,9 +5085,6 @@ packages:
   neo-async@2.6.2:
     resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
 
-  ng2-pdf-viewer@10.4.0:
-    resolution: {integrity: sha512-TPh1oLZoeARggreTG60Sl3ikSn+Z3+At9pLZ0o/vxPjc7mW2ok2XPyl2Oqz7VyP80ipVorldm1hsLPBmNe2zzA==}
-
   ngx-bootstrap-icons@1.9.3:
     resolution: {integrity: sha512-UsFqJ/cn0u5W39hVMIDbm+ze1dCF9fDV839scqeimi70Efcmg41zOx6GgR6i2gWAVFR0OBso1cdqb4E75XhTSw==}
     engines: {node: '>= 16.18.1', npm: '>= 8.11.0'}
@@ -5084,6 +5151,9 @@ packages:
   node-int64@0.4.0:
     resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
 
+  node-readable-to-web-readable-stream@0.4.2:
+    resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==}
+
   node-releases@2.0.27:
     resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
 
@@ -5279,13 +5349,9 @@ packages:
   path-to-regexp@8.3.0:
     resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
 
-  path2d@0.2.2:
-    resolution: {integrity: sha512-+vnG6S4dYcYxZd+CZxzXCNKdELYZSKfohrk98yajCo1PtRoDgCTrrwOvK1GT0UoAdVszagDVllQc0U1vaX4NUQ==}
-    engines: {node: '>=6'}
-
-  pdfjs-dist@4.8.69:
-    resolution: {integrity: sha512-IHZsA4T7YElCKNNXtiLgqScw4zPd3pG9do8UrznC757gMd7UPeHSL2qwNNMJo4r79fl8oj1Xx+1nh2YkzdMpLQ==}
-    engines: {node: '>=18'}
+  pdfjs-dist@5.4.624:
+    resolution: {integrity: sha512-sm6TxKTtWv1Oh6n3C6J6a8odejb5uO4A4zo/2dgkHuC0iu8ZMAXOezEODkVaoVp8nX1Xzr+0WxFJJmUr45hQzg==}
+    engines: {node: '>=20.16.0 || >=22.3.0'}
 
   picocolors@1.1.1:
     resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -8796,6 +8862,54 @@ snapshots:
   '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
     optional: true
 
+  '@napi-rs/canvas-android-arm64@0.1.90':
+    optional: true
+
+  '@napi-rs/canvas-darwin-arm64@0.1.90':
+    optional: true
+
+  '@napi-rs/canvas-darwin-x64@0.1.90':
+    optional: true
+
+  '@napi-rs/canvas-linux-arm-gnueabihf@0.1.90':
+    optional: true
+
+  '@napi-rs/canvas-linux-arm64-gnu@0.1.90':
+    optional: true
+
+  '@napi-rs/canvas-linux-arm64-musl@0.1.90':
+    optional: true
+
+  '@napi-rs/canvas-linux-riscv64-gnu@0.1.90':
+    optional: true
+
+  '@napi-rs/canvas-linux-x64-gnu@0.1.90':
+    optional: true
+
+  '@napi-rs/canvas-linux-x64-musl@0.1.90':
+    optional: true
+
+  '@napi-rs/canvas-win32-arm64-msvc@0.1.90':
+    optional: true
+
+  '@napi-rs/canvas-win32-x64-msvc@0.1.90':
+    optional: true
+
+  '@napi-rs/canvas@0.1.90':
+    optionalDependencies:
+      '@napi-rs/canvas-android-arm64': 0.1.90
+      '@napi-rs/canvas-darwin-arm64': 0.1.90
+      '@napi-rs/canvas-darwin-x64': 0.1.90
+      '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.90
+      '@napi-rs/canvas-linux-arm64-gnu': 0.1.90
+      '@napi-rs/canvas-linux-arm64-musl': 0.1.90
+      '@napi-rs/canvas-linux-riscv64-gnu': 0.1.90
+      '@napi-rs/canvas-linux-x64-gnu': 0.1.90
+      '@napi-rs/canvas-linux-x64-musl': 0.1.90
+      '@napi-rs/canvas-win32-arm64-msvc': 0.1.90
+      '@napi-rs/canvas-win32-x64-msvc': 0.1.90
+    optional: true
+
   '@napi-rs/nice-android-arm-eabi@1.1.1':
     optional: true
 
@@ -11975,11 +12089,6 @@ snapshots:
 
   neo-async@2.6.2: {}
 
-  ng2-pdf-viewer@10.4.0:
-    dependencies:
-      pdfjs-dist: 4.8.69
-      tslib: 2.8.1
-
   ngx-bootstrap-icons@1.9.3(@angular/common@21.1.3(@angular/core@21.1.3(@angular/compiler@21.1.3)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@21.1.3(@angular/compiler@21.1.3)(rxjs@7.8.2)(zone.js@0.16.0)):
     dependencies:
       '@angular/common': 21.1.3(@angular/core@21.1.3(@angular/compiler@21.1.3)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2)
@@ -12060,6 +12169,9 @@ snapshots:
 
   node-int64@0.4.0: {}
 
+  node-readable-to-web-readable-stream@0.4.2:
+    optional: true
+
   node-releases@2.0.27: {}
 
   nopt@9.0.0:
@@ -12291,13 +12403,10 @@ snapshots:
 
   path-to-regexp@8.3.0: {}
 
-  path2d@0.2.2:
-    optional: true
-
-  pdfjs-dist@4.8.69:
+  pdfjs-dist@5.4.624:
     optionalDependencies:
-      canvas: 3.0.0
-      path2d: 0.2.2
+      '@napi-rs/canvas': 0.1.90
+      node-readable-to-web-readable-stream: 0.4.2
 
   picocolors@1.1.1: {}
 
index a6461d350cdfa470f81a487414abce3a63b00d22..86e447b5990abe981d38b2bf5b8e90df2089e340 100644 (file)
@@ -100,10 +100,10 @@ const mock = () => {
   }
 }
 
-Object.defineProperty(window, 'open', { value: jest.fn() })
-Object.defineProperty(window, 'localStorage', { value: mock() })
-Object.defineProperty(window, 'sessionStorage', { value: mock() })
-Object.defineProperty(window, 'getComputedStyle', {
+Object.defineProperty(globalThis, 'open', { value: jest.fn() })
+Object.defineProperty(globalThis, 'localStorage', { value: mock() })
+Object.defineProperty(globalThis, 'sessionStorage', { value: mock() })
+Object.defineProperty(globalThis, 'getComputedStyle', {
   value: () => ['-webkit-appearance'],
 })
 Object.defineProperty(navigator, 'clipboard', {
@@ -115,13 +115,33 @@ Object.defineProperty(navigator, 'canShare', { value: () => true })
 if (!navigator.share) {
   Object.defineProperty(navigator, 'share', { value: jest.fn() })
 }
-if (!URL.createObjectURL) {
-  Object.defineProperty(window.URL, 'createObjectURL', { value: jest.fn() })
+if (!globalThis.URL.createObjectURL) {
+  Object.defineProperty(globalThis.URL, 'createObjectURL', { value: jest.fn() })
 }
-if (!URL.revokeObjectURL) {
-  Object.defineProperty(window.URL, 'revokeObjectURL', { value: jest.fn() })
+if (!globalThis.URL.revokeObjectURL) {
+  Object.defineProperty(globalThis.URL, 'revokeObjectURL', { value: jest.fn() })
 }
-Object.defineProperty(window, 'ResizeObserver', { value: mock() })
+class MockResizeObserver {
+  private readonly callback: ResizeObserverCallback
+
+  constructor(callback: ResizeObserverCallback) {
+    this.callback = callback
+  }
+
+  observe = jest.fn()
+  unobserve = jest.fn()
+  disconnect = jest.fn()
+
+  trigger = (entries: ResizeObserverEntry[] = []) => {
+    this.callback(entries, this)
+  }
+}
+
+Object.defineProperty(globalThis, 'ResizeObserver', {
+  writable: true,
+  configurable: true,
+  value: MockResizeObserver,
+})
 
 if (typeof IntersectionObserver === 'undefined') {
   class MockIntersectionObserver {
@@ -136,7 +156,7 @@ if (typeof IntersectionObserver === 'undefined') {
     takeRecords = jest.fn()
   }
 
-  Object.defineProperty(window, 'IntersectionObserver', {
+  Object.defineProperty(globalThis, 'IntersectionObserver', {
     writable: true,
     configurable: true,
     value: MockIntersectionObserver,
index caec63e3010ec0ea9088fc2ec2d7bcd5c73686b8..d07ee94a368efe52c51605690b483b266e7f111f 100644 (file)
               </div>
               <div class="col">
                 <select class="form-select" formControlName="pdfViewerDefaultZoom">
-                  <option [ngValue]="ZoomSetting.PageWidth" i18n>Fit width</option>
-                  <option [ngValue]="ZoomSetting.PageFit" i18n>Fit page</option>
+                  <option [ngValue]="PdfZoomScale.PageWidth" i18n>Fit width</option>
+                  <option [ngValue]="PdfZoomScale.PageFit" i18n>Fit page</option>
                 </select>
                 <p class="small text-muted mt-1" i18n>Only applies to the Paperless-ngx PDF viewer.</p>
               </div>
index a2cfae81928aa318bf6e84acdcc7c3158a7dc6ad..f548b71f43badc9e515f314d748fbb1131b817e2 100644 (file)
@@ -65,8 +65,8 @@ import { PermissionsUserComponent } from '../../common/input/permissions/permiss
 import { SelectComponent } from '../../common/input/select/select.component'
 import { PageHeaderComponent } from '../../common/page-header/page-header.component'
 import { PdfEditorEditMode } from '../../common/pdf-editor/pdf-editor-edit-mode'
+import { PdfZoomScale } from '../../common/pdf-viewer/pdf-viewer.types'
 import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
-import { ZoomSetting } from '../../document-detail/zoom-setting'
 import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
 
 enum SettingsNavIDs {
@@ -196,7 +196,7 @@ export class SettingsComponent
 
   public readonly GlobalSearchType = GlobalSearchType
 
-  public readonly ZoomSetting = ZoomSetting
+  public readonly PdfZoomScale = PdfZoomScale
 
   public readonly PdfEditorEditMode = PdfEditorEditMode
 
index 2d1d01ca711283b3d7fe532b61cab415c725a026..e73e1fc713bb1034c0fabd1015012a580c3e3ae0 100644 (file)
@@ -1,4 +1,4 @@
-<pdf-viewer [src]="pdfSrc" [render-text]="false" zoom="0.4" (after-load-complete)="pdfLoaded($event)"></pdf-viewer>
+<pngx-pdf-viewer class="visually-hidden" [src]="pdfSrc" [renderMode]="PdfRenderMode.Single" [page]="1" [selectable]="false" (afterLoadComplete)="pdfLoaded($event)"></pngx-pdf-viewer>
 <div class="modal-header">
   <h4 class="modal-title">{{ title }}</h4>
   <button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
@@ -59,7 +59,7 @@
                 <span class="placeholder w-100 h-100"></span>
               </div>
             }
-            <pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [original-size]="false" [show-all]="false" [render-text]="false" (page-rendered)="p.loaded = true"></pdf-viewer>
+            <pngx-pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [renderMode]="PdfRenderMode.Single" (rendered)="p.loaded = true"></pngx-pdf-viewer>
           } @placeholder {
             <div class="placeholder-glow w-100 h-100 z-10">
               <span class="placeholder w-100 h-100"></span>
index c2e29463b5d5e9a623db5fb9eeaf49e1fea63cda..e9eba0fc2320d0a3197fd4687ccd058ae93e5626 100644 (file)
   background-color: gray;
   height: 240px;
 
-  pdf-viewer {
+  pngx-pdf-viewer {
     width: 100%;
     height: 100%;
   }
 }
 
-::ng-deep .ng2-pdf-viewer-container {
+::ng-deep .pngx-pdf-viewer-container {
   overflow: hidden;
 }
 
index c25e215e54cb330186078878f486ecf2f16e4d76..c294516e0d02886db2fbded92fabaef52f51a08c 100644 (file)
@@ -6,12 +6,16 @@ import {
 import { Component, inject } from '@angular/core'
 import { FormsModule } from '@angular/forms'
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
-import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
 import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
 import { DocumentService } from 'src/app/services/rest/document.service'
 import { SettingsService } from 'src/app/services/settings.service'
 import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
+import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
+import {
+  PdfRenderMode,
+  PngxPdfDocumentProxy,
+} from '../pdf-viewer/pdf-viewer.types'
 import { PdfEditorEditMode } from './pdf-editor-edit-mode'
 
 interface PageOperation {
@@ -29,11 +33,12 @@ interface PageOperation {
   imports: [
     DragDropModule,
     FormsModule,
-    PdfViewerModule,
     NgxBootstrapIconsModule,
+    PngxPdfViewerComponent,
   ],
 })
 export class PDFEditorComponent extends ConfirmDialogComponent {
+  PdfRenderMode = PdfRenderMode
   public PdfEditorEditMode = PdfEditorEditMode
 
   private documentService = inject(DocumentService)
@@ -53,7 +58,7 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
     return this.documentService.getPreviewUrl(this.documentID)
   }
 
-  pdfLoaded(pdf: PDFDocumentProxy) {
+  pdfLoaded(pdf: PngxPdfDocumentProxy) {
     this.totalPages = pdf.numPages
     this.pages = Array.from({ length: this.totalPages }, (_, i) => ({
       page: i + 1,
diff --git a/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.html b/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.html
new file mode 100644 (file)
index 0000000..edc8efa
--- /dev/null
@@ -0,0 +1,3 @@
+<div #container class="pngx-pdf-viewer-container">
+  <div #viewer class="pdfViewer"></div>
+</div>
diff --git a/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.scss b/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.scss
new file mode 100644 (file)
index 0000000..eac316f
--- /dev/null
@@ -0,0 +1,153 @@
+:host {
+  display: block;
+  width: 100%;
+  height: 100%;
+  position: relative;
+}
+
+:host ::ng-deep .pngx-pdf-viewer-container {
+  position: absolute;
+  inset: 0;
+  overflow: auto;
+}
+
+:host ::ng-deep .pdfViewer {
+  --scale-factor: 1;
+  --page-bg-color: unset;
+  padding-bottom: 0;
+}
+
+:host ::ng-deep .pdfViewer .page {
+  --user-unit: 1;
+  --total-scale-factor: calc(var(--scale-factor) * var(--user-unit));
+  --scale-round-x: 1px;
+  --scale-round-y: 1px;
+  direction: ltr;
+  margin: 0 auto 10px;
+  border: 0;
+  position: relative;
+  overflow: visible;
+  background-clip: content-box;
+  background-color: var(--page-bg-color, rgb(255 255 255));
+}
+
+:host ::ng-deep .pdfViewer > .page:last-of-type {
+  margin-bottom: 0;
+}
+
+:host ::ng-deep .pdfViewer.singlePageView {
+  display: inline-block;
+}
+
+:host ::ng-deep .pdfViewer.singlePageView .page {
+  margin: 0;
+  border: none;
+}
+
+:host ::ng-deep .pdfViewer .canvasWrapper {
+  overflow: hidden;
+  width: 100%;
+  height: 100%;
+}
+
+:host ::ng-deep .pdfViewer .canvasWrapper canvas {
+  position: absolute;
+  top: 0;
+  left: 0;
+  margin: 0;
+  display: block;
+  width: 100%;
+  height: 100%;
+  contain: content;
+}
+
+:host ::ng-deep .textLayer {
+  position: absolute;
+  text-align: initial;
+  inset: 0;
+  overflow: clip;
+  opacity: 1;
+  line-height: 1;
+  text-size-adjust: none;
+  transform-origin: 0 0;
+  caret-color: CanvasText;
+  z-index: 0;
+  user-select: text;
+  --min-font-size: 1;
+  --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
+  --min-font-size-inv: calc(1 / var(--min-font-size));
+}
+
+:host ::ng-deep .textLayer.highlighting {
+  touch-action: none;
+}
+
+:host ::ng-deep .textLayer :is(span, br) {
+  position: absolute;
+  white-space: pre;
+  color: transparent;
+  cursor: text;
+  transform-origin: 0% 0%;
+}
+
+:host ::ng-deep .textLayer > :not(.markedContent),
+:host ::ng-deep .textLayer .markedContent span:not(.markedContent) {
+  z-index: 1;
+  --font-height: 0;
+  font-size: calc(var(--text-scale-factor) * var(--font-height));
+  --scale-x: 1;
+  --rotate: 0deg;
+  transform: rotate(var(--rotate)) scaleX(var(--scale-x))
+    scale(var(--min-font-size-inv));
+}
+
+:host ::ng-deep .textLayer .markedContent {
+  display: contents;
+}
+
+:host ::ng-deep .textLayer span[role='img'] {
+  user-select: none;
+  cursor: default;
+}
+
+:host ::ng-deep .textLayer .highlight {
+  --highlight-bg-color: rgb(180 0 170 / 0.25);
+  --highlight-selected-bg-color: rgb(0 100 0 / 0.25);
+  --highlight-backdrop-filter: none;
+  --highlight-selected-backdrop-filter: none;
+  margin: -1px;
+  padding: 1px;
+  background-color: var(--highlight-bg-color);
+  backdrop-filter: var(--highlight-backdrop-filter);
+  border-radius: 4px;
+}
+
+:host ::ng-deep .appended:is(.textLayer .highlight) {
+  position: initial;
+}
+
+:host ::ng-deep .begin:is(.textLayer .highlight) {
+  border-radius: 4px 0 0 4px;
+}
+
+:host ::ng-deep .end:is(.textLayer .highlight) {
+  border-radius: 0 4px 4px 0;
+}
+
+:host ::ng-deep .middle:is(.textLayer .highlight) {
+  border-radius: 0;
+}
+
+:host ::ng-deep .selected:is(.textLayer .highlight) {
+  background-color: var(--highlight-selected-bg-color);
+}
+
+:host ::ng-deep .textLayer ::selection {
+  background: rgba(30, 100, 255, 0.35);
+}
+
+:host ::ng-deep .annotationLayer {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+}
diff --git a/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.spec.ts b/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.spec.ts
new file mode 100644 (file)
index 0000000..4703e85
--- /dev/null
@@ -0,0 +1,299 @@
+import { SimpleChange } from '@angular/core'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
+import { PDFSinglePageViewer, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs'
+import { PngxPdfViewerComponent } from './pdf-viewer.component'
+import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
+
+describe('PngxPdfViewerComponent', () => {
+  let fixture: ComponentFixture<PngxPdfViewerComponent>
+  let component: PngxPdfViewerComponent
+
+  const initComponent = async (src = 'test.pdf') => {
+    component.src = src
+    fixture.detectChanges()
+    await fixture.whenStable()
+  }
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [PngxPdfViewerComponent],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(PngxPdfViewerComponent)
+    component = fixture.componentInstance
+  })
+
+  it('loads a document and emits events', async () => {
+    const loadSpy = jest.fn()
+    const renderedSpy = jest.fn()
+    component.afterLoadComplete.subscribe(loadSpy)
+    component.rendered.subscribe(renderedSpy)
+
+    await initComponent()
+
+    expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe(
+      '/assets/js/pdf.worker.min.mjs'
+    )
+    const isVisible = (component as any).findController.onIsPageVisible as
+      | (() => boolean)
+      | undefined
+    expect(isVisible?.()).toBe(true)
+    expect(loadSpy).toHaveBeenCalledWith(
+      expect.objectContaining({ numPages: 1 })
+    )
+    expect(renderedSpy).toHaveBeenCalled()
+    expect((component as any).pdfViewer).toBeInstanceOf(PDFViewer)
+  })
+
+  it('initializes single-page viewer and disables text layer', async () => {
+    component.renderMode = PdfRenderMode.Single
+    component.selectable = false
+
+    await initComponent()
+
+    const viewer = (component as any).pdfViewer as PDFSinglePageViewer & {
+      options: Record<string, unknown>
+    }
+    expect(viewer).toBeInstanceOf(PDFSinglePageViewer)
+    expect(viewer.options.textLayerMode).toBe(0)
+  })
+
+  it('applies zoom, rotation, and page changes', async () => {
+    await initComponent()
+
+    const pageSpy = jest.fn()
+    component.pageChange.subscribe(pageSpy)
+
+    component.zoomScale = PdfZoomScale.PageFit
+    component.zoom = PdfZoomLevel.Two
+    component.rotation = 90
+    component.page = 2
+
+    component.ngOnChanges({
+      zoomScale: new SimpleChange(
+        PdfZoomScale.PageWidth,
+        PdfZoomScale.PageFit,
+        false
+      ),
+      zoom: new SimpleChange(PdfZoomLevel.One, PdfZoomLevel.Two, false),
+      rotation: new SimpleChange(undefined, 90, false),
+      page: new SimpleChange(undefined, 2, false),
+    })
+
+    const viewer = (component as any).pdfViewer as PDFViewer
+    expect(viewer.pagesRotation).toBe(90)
+    expect(viewer.currentPageNumber).toBe(2)
+    expect(pageSpy).toHaveBeenCalledWith(2)
+
+    viewer.currentScale = 1
+    ;(component as any).applyScale()
+    expect(viewer.currentScaleValue).toBe(PdfZoomScale.PageFit)
+    expect(viewer.currentScale).toBe(2)
+
+    const applyScaleSpy = jest.spyOn(component as any, 'applyScale')
+    component.page = 2
+    ;(component as any).lastViewerPage = 2
+    ;(component as any).applyViewerState()
+    expect((component as any).lastViewerPage).toBeUndefined()
+    expect(applyScaleSpy).toHaveBeenCalled()
+  })
+
+  it('dispatches find when search query changes after render', async () => {
+    await initComponent()
+
+    const eventBus = (component as any).eventBus as { dispatch: jest.Mock }
+    const dispatchSpy = jest.spyOn(eventBus, 'dispatch')
+
+    ;(component as any).hasRenderedPage = true
+    component.searchQuery = 'needle'
+    component.ngOnChanges({
+      searchQuery: new SimpleChange('', 'needle', false),
+    })
+
+    expect(dispatchSpy).toHaveBeenCalledWith('find', {
+      query: 'needle',
+      caseSensitive: false,
+      highlightAll: true,
+      phraseSearch: true,
+    })
+
+    component.ngOnChanges({
+      searchQuery: new SimpleChange('needle', 'needle', false),
+    })
+    expect(dispatchSpy).toHaveBeenCalledTimes(1)
+  })
+
+  it('emits error when document load fails', async () => {
+    const errorSpy = jest.fn()
+    component.loadError.subscribe(errorSpy)
+
+    jest.spyOn(pdfjs, 'getDocument').mockImplementationOnce(() => {
+      return {
+        promise: Promise.reject(new Error('boom')),
+        destroy: jest.fn(),
+      } as any
+    })
+
+    await initComponent('bad.pdf')
+
+    expect(errorSpy).toHaveBeenCalled()
+  })
+
+  it('cleans up resources on destroy', async () => {
+    await initComponent()
+
+    const viewer = (component as any).pdfViewer as { cleanup: jest.Mock }
+    const loadingTask = (component as any).loadingTask as unknown as {
+      destroy: jest.Mock
+    }
+    const resizeObserver = (component as any).resizeObserver as unknown as {
+      disconnect: jest.Mock
+    }
+    const eventBus = (component as any).eventBus as { off: jest.Mock }
+
+    jest.spyOn(viewer, 'cleanup')
+    jest.spyOn(loadingTask, 'destroy')
+    jest.spyOn(resizeObserver, 'disconnect')
+    jest.spyOn(eventBus, 'off')
+
+    component.ngOnDestroy()
+
+    expect(eventBus.off).toHaveBeenCalledWith(
+      'pagerendered',
+      expect.any(Function)
+    )
+    expect(eventBus.off).toHaveBeenCalledWith('pagesinit', expect.any(Function))
+    expect(eventBus.off).toHaveBeenCalledWith(
+      'pagechanging',
+      expect.any(Function)
+    )
+    expect(resizeObserver.disconnect).toHaveBeenCalled()
+    expect(loadingTask.destroy).toHaveBeenCalled()
+    expect(viewer.cleanup).toHaveBeenCalled()
+    expect((component as any).pdfViewer).toBeUndefined()
+  })
+
+  it('skips work when viewer is missing or has no pages', () => {
+    const eventBus = (component as any).eventBus as { dispatch: jest.Mock }
+    const dispatchSpy = jest.spyOn(eventBus, 'dispatch')
+    ;(component as any).dispatchFindIfReady()
+    expect(dispatchSpy).not.toHaveBeenCalled()
+    ;(component as any).applyViewerState()
+    ;(component as any).applyScale()
+
+    const viewer = new PDFViewer({ eventBus: undefined })
+    viewer.pagesCount = 0
+    ;(component as any).pdfViewer = viewer
+    viewer.currentScale = 5
+    ;(component as any).applyScale()
+    expect(viewer.currentScale).toBe(5)
+  })
+
+  it('returns early on src change in ngOnChanges', () => {
+    const loadSpy = jest.spyOn(component as any, 'loadDocument')
+    const initSpy = jest.spyOn(component as any, 'initViewer')
+    const scaleSpy = jest.spyOn(component as any, 'applyViewerState')
+    const resizeSpy = jest.spyOn(component as any, 'setupResizeObserver')
+
+    component.ngOnChanges({
+      src: new SimpleChange(undefined, 'test.pdf', true),
+      zoomScale: new SimpleChange(
+        PdfZoomScale.PageWidth,
+        PdfZoomScale.PageFit,
+        false
+      ),
+    })
+
+    expect(loadSpy).toHaveBeenCalled()
+    expect(resizeSpy).not.toHaveBeenCalled()
+    expect(initSpy).not.toHaveBeenCalled()
+    expect(scaleSpy).not.toHaveBeenCalled()
+  })
+
+  it('applies viewer state after view init when already loaded', () => {
+    const applySpy = jest.spyOn(component as any, 'applyViewerState')
+    ;(component as any).hasLoaded = true
+    ;(component as any).pdf = { numPages: 1 }
+
+    fixture.detectChanges()
+
+    expect(applySpy).toHaveBeenCalled()
+  })
+
+  it('skips viewer state after view init when no pdf is available', () => {
+    const applySpy = jest.spyOn(component as any, 'applyViewerState')
+    ;(component as any).hasLoaded = true
+
+    fixture.detectChanges()
+
+    expect(applySpy).not.toHaveBeenCalled()
+  })
+
+  it('does not reload when already loaded', async () => {
+    await initComponent()
+
+    const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
+    const callCount = getDocumentSpy.mock.calls.length
+    await (component as any).loadDocument()
+
+    expect(getDocumentSpy).toHaveBeenCalledTimes(callCount)
+  })
+
+  it('runs applyScale on resize observer notifications', async () => {
+    await initComponent()
+
+    const applySpy = jest.spyOn(component as any, 'applyScale')
+    const resizeObserver = (component as any).resizeObserver as {
+      trigger: () => void
+    }
+    resizeObserver.trigger()
+
+    expect(applySpy).toHaveBeenCalled()
+  })
+
+  it('skips page work when no pages are available', async () => {
+    await initComponent()
+
+    const viewer = (component as any).pdfViewer as PDFViewer
+    viewer.pagesCount = 0
+    const applyScaleSpy = jest.spyOn(component as any, 'applyScale')
+
+    component.page = undefined
+    ;(component as any).lastViewerPage = 1
+    ;(component as any).applyViewerState()
+
+    expect(applyScaleSpy).not.toHaveBeenCalled()
+    expect((component as any).lastViewerPage).toBe(1)
+  })
+
+  it('falls back to a default zoom when input is invalid', async () => {
+    await initComponent()
+
+    const viewer = (component as any).pdfViewer as PDFViewer
+    viewer.currentScale = 3
+    component.zoom = 'not-a-number' as PdfZoomLevel
+    ;(component as any).applyScale()
+
+    expect(viewer.currentScale).toBe(3)
+  })
+
+  it('re-initializes viewer on selectable or render mode changes', async () => {
+    await initComponent()
+
+    const initSpy = jest.spyOn(component as any, 'initViewer')
+    component.selectable = false
+    component.renderMode = PdfRenderMode.Single
+
+    component.ngOnChanges({
+      selectable: new SimpleChange(true, false, false),
+      renderMode: new SimpleChange(
+        PdfRenderMode.All,
+        PdfRenderMode.Single,
+        false
+      ),
+    })
+
+    expect(initSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.ts b/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.ts
new file mode 100644 (file)
index 0000000..4a7008b
--- /dev/null
@@ -0,0 +1,266 @@
+import {
+  AfterViewInit,
+  Component,
+  ElementRef,
+  EventEmitter,
+  Input,
+  OnChanges,
+  OnDestroy,
+  Output,
+  SimpleChanges,
+  ViewChild,
+} from '@angular/core'
+import {
+  getDocument,
+  GlobalWorkerOptions,
+  PDFDocumentLoadingTask,
+  PDFDocumentProxy,
+} from 'pdfjs-dist/legacy/build/pdf.mjs'
+import {
+  EventBus,
+  PDFFindController,
+  PDFLinkService,
+  PDFSinglePageViewer,
+  PDFViewer,
+} from 'pdfjs-dist/web/pdf_viewer.mjs'
+import {
+  PdfRenderMode,
+  PdfSource,
+  PdfZoomLevel,
+  PdfZoomScale,
+  PngxPdfDocumentProxy,
+} from './pdf-viewer.types'
+
+@Component({
+  selector: 'pngx-pdf-viewer',
+  templateUrl: './pdf-viewer.component.html',
+  styleUrl: './pdf-viewer.component.scss',
+})
+export class PngxPdfViewerComponent
+  implements AfterViewInit, OnChanges, OnDestroy
+{
+  @Input() src!: PdfSource
+  @Input() page?: number
+  @Output() pageChange = new EventEmitter<number>()
+  @Input() rotation?: number
+  @Input() renderMode: PdfRenderMode = PdfRenderMode.All
+  @Input() selectable = true
+  @Input() searchQuery = ''
+  @Input() zoom: PdfZoomLevel = PdfZoomLevel.One
+  @Input() zoomScale: PdfZoomScale = PdfZoomScale.PageWidth
+
+  @Output() afterLoadComplete = new EventEmitter<PngxPdfDocumentProxy>()
+  @Output() rendered = new EventEmitter<void>()
+  @Output() loadError = new EventEmitter<unknown>()
+
+  @ViewChild('container', { static: true })
+  private readonly container!: ElementRef<HTMLDivElement>
+
+  @ViewChild('viewer', { static: true })
+  private readonly viewer!: ElementRef<HTMLDivElement>
+
+  private hasLoaded = false
+  private loadingTask?: PDFDocumentLoadingTask
+  private resizeObserver?: ResizeObserver
+  private pdf?: PDFDocumentProxy
+  private pdfViewer?: PDFViewer | PDFSinglePageViewer
+  private hasRenderedPage = false
+  private lastFindQuery = ''
+  private lastViewerPage?: number
+
+  private readonly eventBus = new EventBus()
+  private readonly linkService = new PDFLinkService({ eventBus: this.eventBus })
+  private readonly findController = new PDFFindController({
+    eventBus: this.eventBus,
+    linkService: this.linkService,
+    updateMatchesCountOnProgress: false,
+  })
+
+  private readonly onPageRendered = () => {
+    this.hasRenderedPage = true
+    this.dispatchFindIfReady()
+    this.rendered.emit()
+  }
+  private readonly onPagesInit = () => this.applyScale()
+  private readonly onPageChanging = (evt: { pageNumber: number }) => {
+    // Avoid [(page)] two-way binding re-triggers navigation
+    this.lastViewerPage = evt.pageNumber
+    this.pageChange.emit(evt.pageNumber)
+  }
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes['src']) {
+      this.hasLoaded = false
+      this.loadDocument()
+      return
+    }
+
+    if (changes['zoomScale']) {
+      this.setupResizeObserver()
+    }
+
+    if (changes['selectable'] || changes['renderMode']) {
+      this.initViewer()
+    }
+
+    if (
+      changes['page'] ||
+      changes['zoom'] ||
+      changes['zoomScale'] ||
+      changes['rotation']
+    ) {
+      this.applyViewerState()
+    }
+
+    if (changes['searchQuery']) {
+      this.dispatchFindIfReady()
+    }
+  }
+
+  ngAfterViewInit(): void {
+    this.setupResizeObserver()
+    this.initViewer()
+    if (!this.hasLoaded) {
+      this.loadDocument()
+      return
+    }
+    if (this.pdf) {
+      this.applyViewerState()
+    }
+  }
+
+  ngOnDestroy(): void {
+    this.eventBus.off('pagerendered', this.onPageRendered)
+    this.eventBus.off('pagesinit', this.onPagesInit)
+    this.eventBus.off('pagechanging', this.onPageChanging)
+    this.resizeObserver?.disconnect()
+    this.loadingTask?.destroy()
+    this.pdfViewer?.cleanup()
+    this.pdfViewer = undefined
+  }
+
+  private async loadDocument(): Promise<void> {
+    if (this.hasLoaded) {
+      return
+    }
+
+    this.hasLoaded = true
+    this.hasRenderedPage = false
+    this.lastFindQuery = ''
+    this.loadingTask?.destroy()
+
+    GlobalWorkerOptions.workerSrc = '/assets/js/pdf.worker.min.mjs'
+    this.loadingTask = getDocument(this.src)
+
+    try {
+      const pdf = await this.loadingTask.promise
+      this.pdf = pdf
+      this.linkService.setDocument(pdf)
+      this.findController.onIsPageVisible = () => true
+      this.pdfViewer?.setDocument(pdf)
+      this.applyViewerState()
+      this.afterLoadComplete.emit(pdf)
+    } catch (err) {
+      this.loadError.emit(err)
+    }
+  }
+
+  private setupResizeObserver(): void {
+    this.resizeObserver?.disconnect()
+    this.resizeObserver = new ResizeObserver(() => {
+      this.applyScale()
+    })
+    this.resizeObserver.observe(this.container.nativeElement)
+  }
+
+  private initViewer(): void {
+    this.viewer.nativeElement.innerHTML = ''
+    this.pdfViewer?.cleanup()
+    this.hasRenderedPage = false
+    this.lastFindQuery = ''
+
+    const textLayerMode = this.selectable === false ? 0 : 1
+    const options = {
+      container: this.container.nativeElement,
+      viewer: this.viewer.nativeElement,
+      eventBus: this.eventBus,
+      linkService: this.linkService,
+      findController: this.findController,
+      textLayerMode,
+      removePageBorders: true,
+    }
+
+    this.pdfViewer =
+      this.renderMode === PdfRenderMode.Single
+        ? new PDFSinglePageViewer(options)
+        : new PDFViewer(options)
+    this.linkService.setViewer(this.pdfViewer)
+
+    this.eventBus.off('pagerendered', this.onPageRendered)
+    this.eventBus.off('pagesinit', this.onPagesInit)
+    this.eventBus.off('pagechanging', this.onPageChanging)
+    this.eventBus.on('pagerendered', this.onPageRendered)
+    this.eventBus.on('pagesinit', this.onPagesInit)
+    this.eventBus.on('pagechanging', this.onPageChanging)
+
+    if (this.pdf) {
+      this.pdfViewer.setDocument(this.pdf)
+      this.applyViewerState()
+    }
+  }
+
+  private applyViewerState(): void {
+    if (!this.pdfViewer) {
+      return
+    }
+    const hasPages = this.pdfViewer.pagesCount > 0
+    if (typeof this.rotation === 'number' && hasPages) {
+      this.pdfViewer.pagesRotation = this.rotation
+    }
+    if (
+      typeof this.page === 'number' &&
+      hasPages &&
+      this.page !== this.lastViewerPage
+    ) {
+      this.pdfViewer.currentPageNumber = this.page
+    }
+    if (this.page === this.lastViewerPage) {
+      this.lastViewerPage = undefined
+    }
+    if (hasPages) {
+      this.applyScale()
+    }
+    this.dispatchFindIfReady()
+  }
+
+  private applyScale(): void {
+    if (!this.pdfViewer) {
+      return
+    }
+    if (this.pdfViewer.pagesCount === 0) {
+      return
+    }
+    const zoomFactor = Number(this.zoom) || 1
+    this.pdfViewer.currentScaleValue = this.zoomScale
+    if (zoomFactor !== 1) {
+      this.pdfViewer.currentScale = this.pdfViewer.currentScale * zoomFactor
+    }
+  }
+
+  private dispatchFindIfReady(): void {
+    if (!this.hasRenderedPage) {
+      return
+    }
+    const query = this.searchQuery.trim()
+    if (query === this.lastFindQuery) {
+      return
+    }
+    this.lastFindQuery = query
+    this.eventBus.dispatch('find', {
+      query,
+      caseSensitive: false,
+      highlightAll: query.length > 0,
+      phraseSearch: true,
+    })
+  }
+}
diff --git a/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.types.ts b/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.types.ts
new file mode 100644 (file)
index 0000000..edce9a7
--- /dev/null
@@ -0,0 +1,25 @@
+export type PngxPdfDocumentProxy = {
+  numPages: number
+}
+
+export type PdfSource = string | { url: string; password?: string }
+
+export enum PdfRenderMode {
+  Single = 'single',
+  All = 'all',
+}
+
+export enum PdfZoomScale {
+  PageFit = 'page-fit',
+  PageWidth = 'page-width',
+}
+
+export enum PdfZoomLevel {
+  Quarter = '.25',
+  Half = '.5',
+  ThreeQuarters = '.75',
+  One = '1',
+  OneAndHalf = '1.5',
+  Two = '2',
+  Three = '3',
+}
index 287a659efc3ff0c5ff3000caaf0040226cf49996..e949b7b0bfd9b5181458a911f4386da7175600a1 100644 (file)
           </div>
         }
         @if (!requiresPassword) {
-          <pdf-viewer
+          <pngx-pdf-viewer
             [src]="previewUrl"
-            [original-size]="false"
-            [show-borders]="false"
-            [show-all]="true"
-            (text-layer-rendered)="onPageRendered()"
-            (error)="onError($event)" #pdfViewer>
-          </pdf-viewer>
+            [renderMode]="PdfRenderMode.All"
+            [searchQuery]="documentService.searchQuery"
+            (loadError)="onError($event)">
+          </pngx-pdf-viewer>
         }
       }
     }
index db2c0d3de68ebc455ba0e0f2513c540444952b74..643464ee8fda1e8cda16786149b1b141ba90b672 100644 (file)
@@ -12,6 +12,7 @@ import { of, throwError } from 'rxjs'
 import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
 import { DocumentService } from 'src/app/services/rest/document.service'
 import { SettingsService } from 'src/app/services/settings.service'
+import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
 import { PreviewPopupComponent } from './preview-popup.component'
 
 const doc = {
@@ -78,7 +79,7 @@ describe('PreviewPopupComponent', () => {
     component.popover.open()
     fixture.detectChanges()
     expect(fixture.debugElement.query(By.css('object'))).toBeNull()
-    expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
+    expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
   })
 
   it('should show lock icon on password error', () => {
@@ -159,23 +160,15 @@ describe('PreviewPopupComponent', () => {
     expect(component.popover.isOpen()).toBeFalsy()
   })
 
-  it('should dispatch find event on viewer loaded if searchQuery set', () => {
+  it('should pass searchQuery to viewer', () => {
     documentService.searchQuery = 'test'
     settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
     component.popover.open()
-    jest.advanceTimersByTime(1000)
     fixture.detectChanges()
-    // normally setup by pdf-viewer
-    jest.replaceProperty(component.pdfViewer, 'eventBus', {
-      dispatch: jest.fn(),
-    } as any)
-    const dispatchSpy = jest.spyOn(component.pdfViewer.eventBus, 'dispatch')
-    component.onPageRendered()
-    expect(dispatchSpy).toHaveBeenCalledWith('find', {
-      query: 'test',
-      caseSensitive: false,
-      highlightAll: true,
-      phraseSearch: true,
-    })
+    const viewer = fixture.debugElement.query(
+      By.directive(PngxPdfViewerComponent)
+    )
+    expect(viewer).not.toBeNull()
+    expect(viewer.componentInstance.searchQuery).toBe('test')
   })
 })
index 4528dff8afb608f6ab57a7eb2c5d8f61db5291b2..9c42d1a61530abbb59a95d3687e9330dbce6208e 100644 (file)
@@ -1,7 +1,6 @@
 import { HttpClient } from '@angular/common/http'
 import { Component, inject, Input, OnDestroy, ViewChild } from '@angular/core'
 import { NgbPopover, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
-import { PdfViewerComponent, PdfViewerModule } from 'ng2-pdf-viewer'
 import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 import { first, Subject, takeUntil } from 'rxjs'
 import { Document } from 'src/app/data/document'
@@ -10,6 +9,8 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
 import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
 import { DocumentService } from 'src/app/services/rest/document.service'
 import { SettingsService } from 'src/app/services/settings.service'
+import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
+import { PdfRenderMode } from '../pdf-viewer/pdf-viewer.types'
 
 @Component({
   selector: 'pngx-preview-popup',
@@ -18,14 +19,15 @@ import { SettingsService } from 'src/app/services/settings.service'
   imports: [
     NgbPopoverModule,
     DocumentTitlePipe,
-    PdfViewerModule,
+    PngxPdfViewerComponent,
     SafeUrlPipe,
     NgxBootstrapIconsModule,
   ],
 })
 export class PreviewPopupComponent implements OnDestroy {
+  PdfRenderMode = PdfRenderMode
   private settingsService = inject(SettingsService)
-  private documentService = inject(DocumentService)
+  public readonly documentService = inject(DocumentService)
   private http = inject(HttpClient)
 
   private _document: Document
@@ -61,8 +63,6 @@ export class PreviewPopupComponent implements OnDestroy {
 
   @ViewChild('popover') popover: NgbPopover
 
-  @ViewChild('pdfViewer') pdfViewer: PdfViewerComponent
-
   mouseOnPreview: boolean = false
 
   popoverClass: string = 'shadow popover-preview'
@@ -114,18 +114,6 @@ export class PreviewPopupComponent implements OnDestroy {
     }
   }
 
-  onPageRendered() {
-    // Only triggered by the pngx pdf viewer
-    if (this.documentService.searchQuery) {
-      this.pdfViewer.eventBus.dispatch('find', {
-        query: this.documentService.searchQuery,
-        caseSensitive: false,
-        highlightAll: true,
-        phraseSearch: true,
-      })
-    }
-  }
-
   mouseEnterPreview() {
     this.mouseOnPreview = true
     if (!this.popover.isOpen()) {
index 306152cc4fa1e5260c282dcc555e35dbe8f98fb3..c2b545b09c128ed87e238222451c26530e2d2094 100644 (file)
       @case (ContentRenderType.PDF) {
         @if (!useNativePdfViewer) {
           <div class="preview-sticky pdf-viewer-container">
-            <pdf-viewer
+            <pngx-pdf-viewer
               [src]="{ url: previewUrl, password: password }"
-              [original-size]="false"
-              [show-borders]="true"
-              [show-all]="true"
+              [renderMode]="PdfRenderMode.All"
               [(page)]="previewCurrentPage"
-              [zoom-scale]="previewZoomScale"
+              [zoomScale]="previewZoomScale"
               [zoom]="previewZoomSetting"
-              (error)="onError($event)"
-              (after-load-complete)="pdfPreviewLoaded($event)">
-            </pdf-viewer>
+              (loadError)="onError($event)"
+              (afterLoadComplete)="pdfPreviewLoaded($event)">
+            </pngx-pdf-viewer>
           </div>
         } @else {
           <object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
index 3fc009020f601ca53fc0ec04d28298b19344d570..3986f2cbc17d2857766c92f51fcfa4e6d71688b5 100644 (file)
@@ -5,20 +5,15 @@
 }
 
 .pdf-viewer-container {
-  padding-top: 10px;
+  padding: 8px;
   background-color: gray;
 
-  pdf-viewer {
+  pngx-pdf-viewer {
     width: 100%;
     height: 100%;
   }
 }
 
-::ng-deep .ng2-pdf-viewer-container .page {
-  --page-margin: 0 auto 10px;
-  --page-border: 0;
-}
-
 .btn-group .dropdown-toggle-split {
   border-top-right-radius: inherit;
   border-bottom-right-radius: inherit;
index 7be9d915140518cf8e3a2b6d39bc7d7a42bfc998..4f299e26ac2a079bf7c935a3784a66b5c2293f59 100644 (file)
@@ -69,8 +69,11 @@ import { environment } from 'src/environments/environment'
 import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
 import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
 import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
+import {
+  PdfZoomLevel,
+  PdfZoomScale,
+} from '../common/pdf-viewer/pdf-viewer.types'
 import { DocumentDetailComponent } from './document-detail.component'
-import { ZoomSetting } from './zoom-setting'
 
 const doc: Document = {
   id: 3,
@@ -860,7 +863,7 @@ describe('DocumentDetailComponent', () => {
 
   it('should support zoom controls', () => {
     initNormally()
-    component.setZoom(ZoomSetting.One) // from select
+    component.setZoom(PdfZoomLevel.One) // from select
     expect(component.previewZoomSetting).toEqual('1')
     component.increaseZoom()
     expect(component.previewZoomSetting).toEqual('1.5')
@@ -868,18 +871,18 @@ describe('DocumentDetailComponent', () => {
     expect(component.previewZoomSetting).toEqual('2')
     component.decreaseZoom()
     expect(component.previewZoomSetting).toEqual('1.5')
-    component.setZoom(ZoomSetting.One) // from select
+    component.setZoom(PdfZoomLevel.One) // from select
     component.decreaseZoom()
     expect(component.previewZoomSetting).toEqual('.75')
 
-    component.setZoom(ZoomSetting.PageFit) // from select
+    component.setZoom(PdfZoomScale.PageFit) // from select
     expect(component.previewZoomScale).toEqual('page-fit')
     expect(component.previewZoomSetting).toEqual('1')
     component.increaseZoom()
     expect(component.previewZoomSetting).toEqual('1.5')
     expect(component.previewZoomScale).toEqual('page-width')
 
-    component.setZoom(ZoomSetting.PageFit) // from select
+    component.setZoom(PdfZoomScale.PageFit) // from select
     expect(component.previewZoomScale).toEqual('page-fit')
     expect(component.previewZoomSetting).toEqual('1')
     component.decreaseZoom()
@@ -889,10 +892,10 @@ describe('DocumentDetailComponent', () => {
 
   it('should select correct zoom setting in dropdown', () => {
     initNormally()
-    component.setZoom(ZoomSetting.PageFit)
-    expect(component.currentZoom).toEqual(ZoomSetting.PageFit)
-    component.setZoom(ZoomSetting.Quarter)
-    expect(component.currentZoom).toEqual(ZoomSetting.Quarter)
+    component.setZoom(PdfZoomScale.PageFit)
+    expect(component.currentZoom).toEqual(PdfZoomScale.PageFit)
+    component.setZoom(PdfZoomLevel.Quarter)
+    expect(component.currentZoom).toEqual(PdfZoomLevel.Quarter)
   })
 
   it('should support updating notes dynamically', () => {
@@ -1017,7 +1020,7 @@ describe('DocumentDetailComponent', () => {
     settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
     expect(component.useNativePdfViewer).toBeFalsy()
     fixture.detectChanges()
-    expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
+    expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
   })
 
   it('should display native pdf viewer if enabled', () => {
index 016a94d710a9fa9254be0018ee6c7e4058765e61..349d3d199f15b08d6ef3f6bea2ebb885053d61c2 100644 (file)
@@ -18,7 +18,6 @@ import {
   NgbNavModule,
 } from '@ng-bootstrap/ng-bootstrap'
 import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
-import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
 import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 import { DeviceDetectorService } from 'ngx-device-detector'
 import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
@@ -108,13 +107,19 @@ import { UrlComponent } from '../common/input/url/url.component'
 import { PageHeaderComponent } from '../common/page-header/page-header.component'
 import { PdfEditorEditMode } from '../common/pdf-editor/pdf-editor-edit-mode'
 import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component'
+import { PngxPdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
+import {
+  PdfRenderMode,
+  PdfZoomLevel,
+  PdfZoomScale,
+  PngxPdfDocumentProxy,
+} from '../common/pdf-viewer/pdf-viewer.types'
 import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
 import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
 import { DocumentHistoryComponent } from '../document-history/document-history.component'
 import { DocumentNotesComponent } from '../document-notes/document-notes.component'
 import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
 import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
-import { ZoomSetting } from './zoom-setting'
 
 enum DocumentDetailNavIDs {
   Details = 1,
@@ -168,16 +173,17 @@ enum ContentRenderType {
     NgbNavModule,
     NgbDropdownModule,
     NgxBootstrapIconsModule,
-    PdfViewerModule,
     TextAreaComponent,
     RouterModule,
+    PngxPdfViewerComponent,
   ],
 })
 export class DocumentDetailComponent
   extends ComponentWithPermissions
   implements OnInit, OnDestroy, DirtyComponent
 {
-  private documentsService = inject(DocumentService)
+  PdfRenderMode = PdfRenderMode
+  documentsService = inject(DocumentService)
   private route = inject(ActivatedRoute)
   private tagService = inject(TagService)
   private correspondentService = inject(CorrespondentService)
@@ -246,8 +252,8 @@ export class DocumentDetailComponent
 
   previewCurrentPage: number = 1
   previewNumPages: number
-  previewZoomSetting: ZoomSetting = ZoomSetting.One
-  previewZoomScale: ZoomSetting = ZoomSetting.PageWidth
+  previewZoomSetting: PdfZoomLevel = PdfZoomLevel.One
+  previewZoomScale: PdfZoomScale = PdfZoomScale.PageWidth
 
   store: BehaviorSubject<any>
   isDirty$: Observable<boolean>
@@ -503,7 +509,9 @@ export class DocumentDetailComponent
   }
 
   ngOnInit(): void {
-    this.setZoom(this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING))
+    this.setZoom(
+      this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale
+    )
     this.documentForm.valueChanges
       .pipe(takeUntil(this.unsubscribeNotifier))
       .subscribe((values) => {
@@ -1204,7 +1212,7 @@ export class DocumentDetailComponent
       })
   }
 
-  pdfPreviewLoaded(pdf: PDFDocumentProxy) {
+  pdfPreviewLoaded(pdf: PngxPdfDocumentProxy) {
     this.previewNumPages = pdf.numPages
     if (this.password) this.requiresPassword = false
     setTimeout(() => {
@@ -1225,31 +1233,33 @@ export class DocumentDetailComponent
     }
   }
 
-  setZoom(setting: ZoomSetting) {
-    if (ZoomSetting.PageFit === setting || ZoomSetting.PageWidth === setting) {
+  setZoom(setting: PdfZoomScale | PdfZoomLevel) {
+    if (
+      setting === PdfZoomScale.PageFit ||
+      setting === PdfZoomScale.PageWidth
+    ) {
       this.previewZoomScale = setting
-      this.previewZoomSetting = ZoomSetting.One
-    } else {
-      this.previewZoomSetting = setting
-      this.previewZoomScale = ZoomSetting.PageWidth
+      this.previewZoomSetting = PdfZoomLevel.One
+      return
     }
+    this.previewZoomSetting = setting
+    this.previewZoomScale = PdfZoomScale.PageWidth
   }
 
   get zoomSettings() {
-    return Object.values(ZoomSetting).filter(
-      (setting) => setting !== ZoomSetting.PageWidth
-    )
+    return [PdfZoomScale.PageFit, ...Object.values(PdfZoomLevel)]
   }
 
   get currentZoom() {
-    if (this.previewZoomScale === ZoomSetting.PageFit) {
-      return ZoomSetting.PageFit
-    } else return this.previewZoomSetting
+    if (this.previewZoomScale === PdfZoomScale.PageFit) {
+      return PdfZoomScale.PageFit
+    }
+    return this.previewZoomSetting
   }
 
-  getZoomSettingTitle(setting: ZoomSetting): string {
+  getZoomSettingTitle(setting: PdfZoomScale | PdfZoomLevel): string {
     switch (setting) {
-      case ZoomSetting.PageFit:
+      case PdfZoomScale.PageFit:
         return $localize`Page Fit`
       default:
         return `${parseFloat(setting) * 100}%`
@@ -1257,25 +1267,24 @@ export class DocumentDetailComponent
   }
 
   increaseZoom(): void {
-    let currentIndex = Object.values(ZoomSetting).indexOf(
-      this.previewZoomSetting
-    )
-    if (this.previewZoomScale === ZoomSetting.PageFit) currentIndex = 5
-    this.previewZoomScale = ZoomSetting.PageWidth
+    const zoomLevels = Object.values(PdfZoomLevel)
+    let currentIndex = zoomLevels.indexOf(this.previewZoomSetting)
+    if (this.previewZoomScale === PdfZoomScale.PageFit) {
+      currentIndex = zoomLevels.indexOf(PdfZoomLevel.One)
+    }
+    this.previewZoomScale = PdfZoomScale.PageWidth
     this.previewZoomSetting =
-      Object.values(ZoomSetting)[
-        Math.min(Object.values(ZoomSetting).length - 1, currentIndex + 1)
-      ]
+      zoomLevels[Math.min(zoomLevels.length - 1, currentIndex + 1)]
   }
 
   decreaseZoom(): void {
-    let currentIndex = Object.values(ZoomSetting).indexOf(
-      this.previewZoomSetting
-    )
-    if (this.previewZoomScale === ZoomSetting.PageFit) currentIndex = 4
-    this.previewZoomScale = ZoomSetting.PageWidth
-    this.previewZoomSetting =
-      Object.values(ZoomSetting)[Math.max(2, currentIndex - 1)]
+    const zoomLevels = Object.values(PdfZoomLevel)
+    let currentIndex = zoomLevels.indexOf(this.previewZoomSetting)
+    if (this.previewZoomScale === PdfZoomScale.PageFit) {
+      currentIndex = zoomLevels.indexOf(PdfZoomLevel.ThreeQuarters)
+    }
+    this.previewZoomScale = PdfZoomScale.PageWidth
+    this.previewZoomSetting = zoomLevels[Math.max(0, currentIndex - 1)]
   }
 
   get showPermissions(): boolean {
diff --git a/src-ui/src/app/components/document-detail/zoom-setting.ts b/src-ui/src/app/components/document-detail/zoom-setting.ts
deleted file mode 100644 (file)
index 27d4f16..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-export enum ZoomSetting {
-  PageFit = 'page-fit',
-  PageWidth = 'page-width',
-  Quarter = '.25',
-  Half = '.5',
-  ThreeQuarters = '.75',
-  One = '1',
-  OneAndHalf = '1.5',
-  Two = '2',
-  Three = '3',
-}
index f899cffa40c03e49eeb56005bf03104b97722908..9b72cb1d638f695e84b312525f79f633ad0b2883 100644 (file)
@@ -1,5 +1,5 @@
 import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode'
-import { ZoomSetting } from '../components/document-detail/zoom-setting'
+import { PdfZoomScale } from '../components/common/pdf-viewer/pdf-viewer.types'
 import { User } from './user'
 
 export interface UiSettings {
@@ -310,7 +310,7 @@ export const SETTINGS: UiSetting[] = [
   {
     key: SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
     type: 'string',
-    default: ZoomSetting.PageWidth,
+    default: PdfZoomScale.PageWidth,
   },
   {
     key: SETTINGS_KEYS.AI_ENABLED,
index b03ce4c2be2aefb2cd2a1dad5a6181a3c3cf2a7d..cccfc96db3bcc5b7617f2079fa5f2e7f76216327 100644 (file)
@@ -21,7 +21,6 @@ import {
   NgbModule,
 } from '@ng-bootstrap/ng-bootstrap'
 import { NgSelectModule } from '@ng-select/ng-select'
-import { PdfViewerModule } from 'ng2-pdf-viewer'
 import {
   NgxBootstrapIconsModule,
   airplane,
@@ -371,7 +370,6 @@ bootstrapApplication(AppComponent, {
       NgbModule,
       FormsModule,
       ReactiveFormsModule,
-      PdfViewerModule,
       NgSelectModule,
       ColorSliderModule,
       DragDropModule,
diff --git a/src-ui/src/test/mocks/pdfjs-legacy-build-pdf.ts b/src-ui/src/test/mocks/pdfjs-legacy-build-pdf.ts
new file mode 100644 (file)
index 0000000..30e3ac4
--- /dev/null
@@ -0,0 +1,24 @@
+export class PDFDocumentProxy {
+  numPages = 1
+}
+
+export class PDFDocumentLoadingTask {
+  promise: Promise<PDFDocumentProxy>
+  destroyed = false
+
+  constructor(promise: Promise<PDFDocumentProxy>) {
+    this.promise = promise
+  }
+
+  destroy(): void {
+    this.destroyed = true
+  }
+}
+
+export const GlobalWorkerOptions = {
+  workerSrc: '',
+}
+
+export const getDocument = (_src: unknown): PDFDocumentLoadingTask => {
+  return new PDFDocumentLoadingTask(Promise.resolve(new PDFDocumentProxy()))
+}
diff --git a/src-ui/src/test/mocks/pdfjs-web-pdf_viewer.ts b/src-ui/src/test/mocks/pdfjs-web-pdf_viewer.ts
new file mode 100644 (file)
index 0000000..601b05c
--- /dev/null
@@ -0,0 +1,79 @@
+type EventHandler = (event?: unknown) => void
+
+export class EventBus {
+  private readonly listeners = new Map<string, Set<EventHandler>>()
+
+  on(eventName: string, listener: EventHandler): void {
+    let listeners = this.listeners.get(eventName)
+    if (!listeners) {
+      listeners = new Set()
+      this.listeners.set(eventName, listeners)
+    }
+    listeners.add(listener)
+  }
+
+  off(eventName: string, listener: EventHandler): void {
+    this.listeners.get(eventName)?.delete(listener)
+  }
+
+  dispatch(eventName: string, event?: unknown): void {
+    this.listeners.get(eventName)?.forEach((listener) => listener(event))
+  }
+}
+
+export class PDFFindController {
+  onIsPageVisible?: () => boolean
+}
+
+export class PDFLinkService {
+  private document?: unknown
+  private viewer?: unknown
+
+  setDocument(document: unknown): void {
+    this.document = document
+  }
+
+  setViewer(viewer: unknown): void {
+    this.viewer = viewer
+  }
+}
+
+class BaseViewer {
+  pagesCount = 0
+  currentScale = 1
+  currentScaleValue: string | number = 1
+  pagesRotation = 0
+  readonly options: Record<string, unknown>
+
+  private readonly eventBus?: EventBus
+  private _currentPageNumber = 1
+
+  constructor(options: { eventBus?: EventBus }) {
+    this.options = options
+    this.eventBus = options.eventBus
+  }
+
+  setDocument(document: { numPages?: number } | null | undefined): void {
+    this.pagesCount = document?.numPages ?? 1
+    this.eventBus?.dispatch('pagesinit', {})
+    this.eventBus?.dispatch('pagerendered', {
+      pageNumber: this._currentPageNumber,
+    })
+  }
+
+  cleanup(): void {
+    this.pagesCount = 0
+  }
+
+  get currentPageNumber(): number {
+    return this._currentPageNumber
+  }
+
+  set currentPageNumber(value: number) {
+    this._currentPageNumber = value
+    this.eventBus?.dispatch('pagechanging', { pageNumber: value })
+  }
+}
+
+export class PDFViewer extends BaseViewer {}
+export class PDFSinglePageViewer extends BaseViewer {}
index b32c2ad5b37991725dea83f3511806539ddac36c..af936d615a952dc3bf748e0792a79451f72abac6 100644 (file)
@@ -17,6 +17,7 @@
   ],
   "include": [
     "src/**/*.spec.ts",
-    "src/**/*.d.ts"
+    "src/**/*.d.ts",
+    "src/test/**/*.ts"
   ]
 }