From 76bb4330b64be99d9943748553d773925fa10e83 Mon Sep 17 00:00:00 2001 From: Helias Date: Wed, 11 Jun 2025 21:51:29 +0200 Subject: [PATCH 1/9] feat: implement zooming via mouse wheel and touchscreen --- README.md | 313 ++++++++++++--------- src/app/pdf-viewer/pdf-viewer.component.ts | 139 ++++++--- src/app/pdf-viewer/zoom.service.ts | 131 +++++++++ 3 files changed, 404 insertions(+), 179 deletions(-) create mode 100644 src/app/pdf-viewer/zoom.service.ts diff --git a/README.md b/README.md index 4c5223ba..0e4d9720 100644 --- a/README.md +++ b/README.md @@ -30,37 +30,41 @@ ## Overview -* [Install](#install) -* [Usage](#usage) -* [Options](#options) -* [Render local PDF file](#render-local-pdf-file) -* [Set custom path to the worker](#set-custom-path-to-the-worker) -* [Search in the PDF](#search-in-the-pdf) -* [Contribute](#contribute) +- [Install](#install) +- [Usage](#usage) +- [Options](#options) +- [Render local PDF file](#render-local-pdf-file) +- [Set custom path to the worker](#set-custom-path-to-the-worker) +- [Search in the PDF](#search-in-the-pdf) +- [Contribute](#contribute) ## Install ### Angular >= 12 + ``` npm install ng2-pdf-viewer ``` + > Partial Ivy compilated library bundles. ### Angular >= 4 + ``` npm install ng2-pdf-viewer@^7.0.0 ``` ### Angular < 4 + ``` npm install ng2-pdf-viewer@~3.0.8 ``` ## Usage -*In case you're using ```systemjs``` see configuration [here](https://github.com/VadimDez/ng2-pdf-viewer/blob/master/SYSTEMJS.md).* +_In case you're using `systemjs` see configuration [here](https://github.com/VadimDez/ng2-pdf-viewer/blob/master/SYSTEMJS.md)._ -Add ```PdfViewerModule``` to your module's ```imports``` +Add `PdfViewerModule` to your module's `imports` ```typescript import { NgModule } from '@angular/core'; @@ -72,9 +76,8 @@ import { PdfViewerModule } from 'ng2-pdf-viewer'; @NgModule({ imports: [BrowserModule, PdfViewerModule], declarations: [AppComponent], - bootstrap: [AppComponent] + bootstrap: [AppComponent], }) - class AppModule {} platformBrowserDynamic().bootstrapModule(AppModule); @@ -87,47 +90,41 @@ import { Component } from '@angular/core'; @Component({ selector: 'example-app', - template: ` - - ` + template: ` `, }) export class AppComponent { - pdfSrc = "https://vadimdez.github.io/ng2-pdf-viewer/assets/pdf-test.pdf"; + pdfSrc = 'https://vadimdez.github.io/ng2-pdf-viewer/assets/pdf-test.pdf'; } ``` ## Options -* [[src]](#src) -* [[(page)]](#page) -* [[stick-to-page]](#stick-to-page) -* [[external-link-target]](#external-link-target) -* [[render-text]](#render-text) -* [[render-text-mode]](#render-text-mode) -* [[rotation]](#rotation) -* [[zoom]](#zoom) -* [[zoom-scale]](#zoom-scale) -* [[original-size]](#original-size) -* [[fit-to-page]](#fit-to-page) -* [[show-all]](#show-all) -* [[autoresize]](#autoresize) -* [[c-maps-url]](#c-maps-url) -* [[show-borders]](#show-borders) -* [(after-load-complete)](#after-load-complete) -* [(page-rendered)](#page-rendered) -* [(text-layer-rendered)](#text-layer-rendered) -* [(error)](#error) -* [(on-progress)](#on-progress) +- [[src]](#src) +- [[(page)]](#page) +- [[stick-to-page]](#stick-to-page) +- [[external-link-target]](#external-link-target) +- [[render-text]](#render-text) +- [[render-text-mode]](#render-text-mode) +- [[rotation]](#rotation) +- [[zoom]](#zoom) +- [[zoom-scale]](#zoom-scale) +- [[original-size]](#original-size) +- [[fit-to-page]](#fit-to-page) +- [[show-all]](#show-all) +- [[autoresize]](#autoresize) +- [[c-maps-url]](#c-maps-url) +- [[show-borders]](#show-borders) +- [(after-load-complete)](#after-load-complete) +- [(page-rendered)](#page-rendered) +- [(text-layer-rendered)](#text-layer-rendered) +- [(error)](#error) +- [(on-progress)](#on-progress) #### [src] -| Property | Type | Required | -| --- | ---- | --- | -| [src] | *string, object, UInt8Array* | Required | +| Property | Type | Required | +| -------- | ---------------------------- | -------- | +| [src] | _string, object, UInt8Array_ | Required | Pass pdf location @@ -135,46 +132,48 @@ Pass pdf location [src]="'https://vadimdez.github.io/ng2-pdf-viewer/assets/pdf-test.pdf'" ``` -For more control you can pass options object to ```[src]```. [See other attributes for the object here](https://github.com/mozilla/pdf.js/blob/master/src/display/api.js#L130-L222). +For more control you can pass options object to `[src]`. [See other attributes for the object here](https://github.com/mozilla/pdf.js/blob/master/src/display/api.js#L130-L222). Options object for loading protected PDF would be: - ```js - { - url: 'https://vadimdez.github.io/ng2-pdf-viewer/assets/pdf-test.pdf', - withCredentials: true - } - ``` +```js +{ + url: 'https://vadimdez.github.io/ng2-pdf-viewer/assets/pdf-test.pdf', + withCredentials: true +} +``` #### [page] - -| Property | Type | Required | -| --- | ---- | --- | -| [page] or [(page)] | *number* | *Required* with [show-all]="false" or *Optional* with [show-all]="true" | +| Property | Type | Required | +| ------------------ | -------- | ----------------------------------------------------------------------- | +| [page] or [(page)] | _number_ | _Required_ with [show-all]="false" or _Optional_ with [show-all]="true" | Page number ``` [page]="1" ``` + supports two way data binding as well + ``` [(page)]="pageVariable" ``` If you want that the `two way data binding` actually updates your `page` variable on page change/scroll - you have to be sure that you define the height of the container, for example: + ```css pdf-viewer { - height: 100vh; + height: 100vh; } ``` #### [stick-to-page] -| Property | Type | Required | -| --- | ---- | --- | -| [stick-to-page] | *boolean* | *Optional* | +| Property | Type | Required | +| --------------- | --------- | ---------- | +| [stick-to-page] | _boolean_ | _Optional_ | Sticks view to the page. Works in combination with `[show-all]="true"` and `page`. @@ -184,20 +183,21 @@ Sticks view to the page. Works in combination with `[show-all]="true"` and `page #### [render-text] -| Property | Type | Required | -| --- | ---- | --- | -| [render-text] | *boolean* | *Optional* | +| Property | Type | Required | +| ------------- | --------- | ---------- | +| [render-text] | _boolean_ | _Optional_ | Enable text rendering, allows to select text + ``` [render-text]="true" ``` #### [render-text-mode] -| Property | Type | Required | -| --- | ---- | --- | -| [render-text-mode] | *RenderTextMode* | *Optional* | +| Property | Type | Required | +| ------------------ | ---------------- | ---------- | +| [render-text-mode] | _RenderTextMode_ | _Optional_ | Used in combination with `[render-text]="true"` @@ -215,59 +215,63 @@ Controls if the text layer is enabled, and the selection mode that is used. #### [external-link-target] -| Property | Type | Required | -| --- | ---- | --- | -| [external-link-target] | *string* | *Optional* | +| Property | Type | Required | +| ---------------------- | -------- | ---------- | +| [external-link-target] | _string_ | _Optional_ | Used in combination with `[render-text]="true"` Link target -* `blank` -* `none` -* `self` -* `parent` -* `top` + +- `blank` +- `none` +- `self` +- `parent` +- `top` + ``` [external-link-target]="'blank'" ``` #### [rotation] -| Property | Type | Required | -| --- | ---- | --- | -| [rotation] | *number* | *Optional* | +| Property | Type | Required | +| ---------- | -------- | ---------- | +| [rotation] | _number_ | _Optional_ | Rotate PDF -*Allowed step is 90 degree, ex. 0, 90, 180* +_Allowed step is 90 degree, ex. 0, 90, 180_ + ``` [rotation]="90" ``` #### [zoom] -| Property | Type | Required | -| --- | ---- | --- | -| [zoom] | *number* | *Optional* | +| Property | Type | Required | +| -------- | -------- | ---------- | +| [zoom] | _number_ | _Optional_ | Zoom pdf + ``` [zoom]="0.5" ``` #### [zoom-scale] -| Property | Type | Required | -| --- | ---- | --- | -| [zoom-scale] | *'page-width'\|'page-fit'\|'page-height'* | *Optional* | +| Property | Type | Required | +| ------------ | ----------------------------------------- | ---------- | +| [zoom-scale] | _'page-width'\|'page-fit'\|'page-height'_ | _Optional_ | -Defines how the Zoom scale is computed when `[original-size]="false"`, by default set to 'page-width'. +Defines how the Zoom scale is computed when `[original-size]="false"`, by default set to 'page-width'. -- *'page-width'* with zoom of 1 will display a page width that take all the possible horizontal space in the container +- _'page-width'_ with zoom of 1 will display a page width that take all the possible horizontal space in the container -- *'page-height'* with zoom of 1 will display a page height that take all the possible vertical space in the container +- _'page-height'_ with zoom of 1 will display a page height that take all the possible vertical space in the container -- *'page-fit'* with zoom of 1 will display a page that will be scaled to either width or height to fit completely in the container +- _'page-fit'_ with zoom of 1 will display a page that will be scaled to either width or height to fit completely in the container ``` [zoom-scale]="'page-width'" @@ -275,12 +279,12 @@ Defines how the Zoom scale is computed when `[original-size]="false"`, by defau #### [original-size] -| Property | Type | Required | -| --- | ---- | --- | -| [original-size] | *boolean* | *Optional* | +| Property | Type | Required | +| --------------- | --------- | ---------- | +| [original-size] | _boolean_ | _Optional_ | -* if set to *true* - size will be as same as original document -* if set to *false* - size will be as same as container block +- if set to _true_ - size will be as same as original document +- if set to _false_ - size will be as same as container block ``` [original-size]="true" @@ -288,9 +292,9 @@ Defines how the Zoom scale is computed when `[original-size]="false"`, by defau #### [fit-to-page] -| Property | Type | Required | -| --- | ---- | --- | -| [fit-to-page] | *boolean* | *Optional* | +| Property | Type | Required | +| ------------- | --------- | ---------- | +| [fit-to-page] | _boolean_ | _Optional_ | Works in combination with `[original-size]="true"`. You can show your document in original size, and make sure that it's not bigger then container block. @@ -300,9 +304,9 @@ Works in combination with `[original-size]="true"`. You can show your document i #### [show-all] -| Property | Type | Required | -| --- | ---- | --- | -| [show-all] | *boolean* | *Optional* | +| Property | Type | Required | +| ---------- | --------- | ---------- | +| [show-all] | _boolean_ | _Optional_ | Show single or all pages altogether @@ -312,9 +316,9 @@ Show single or all pages altogether #### [autoresize] -| Property | Type | Required | -| --- | ---- | --- | -| [autoresize] | *boolean* | *Optional* | +| Property | Type | Required | +| ------------ | --------- | ---------- | +| [autoresize] | _boolean_ | _Optional_ | Turn on or off auto resize. @@ -324,41 +328,81 @@ Turn on or off auto resize. [autoresize]="true" ``` +### [isZoomOptimization] + +| Property | Type | Required | +| -------------------- | --------- | -------- | +| [isZoomOptimization] | _boolean_ | Optional | + +Default value: true. It improves the zoom keeping the view in the same position calculating the scrollbars positions before and after the zooming. +It's always true if [isWheelZoom] is enabled. + +``` +[isZoomOptimization]="true" +``` + +### [isWheelZoom] + +| Property | Type | Required | +| ------------- | --------- | -------- | +| [isWheelZoom] | _boolean_ | Optional | + +Default value: true. It allows to zoom via the mouse wheel, optionally the zoom can be performed using or not the Ctrl Key depending on [isWheelCtrlZoom]. + +``` +[isWheelZoom]="true" +``` + +### [isWheelCtrlZoom] + +| Property | Type | Required | +| ----------------- | --------- | -------- | +| [isWheelCtrlZoom] | _boolean_ | Optional | + +Default value: true. It allows to zoom via the mouse wheel keeping the Ctrl button pressed. If disabled, the mouse wheel will zoom instead of scrolling the bars. + +``` +[isWheelCtrlZoom]="true" +``` + #### [c-maps-url] -| Property | Type | Required | -| --- | ---- | --- | -| [c-maps-url] | *string* | Optional | +| Property | Type | Required | +| ------------ | -------- | -------- | +| [c-maps-url] | _string_ | Optional | Url for non-latin characters source maps. + ``` [c-maps-url]="'assets/cmaps/'" ``` Default url is: [https://unpkg.com/pdfjs-dist@2.0.550/cmaps/](https://unpkg.com/pdfjs-dist@2.0.550/cmaps/) -To serve cmaps on your own you need to copy ```node_modules/pdfjs-dist/cmaps``` to ```assets/cmaps```. +To serve cmaps on your own you need to copy `node_modules/pdfjs-dist/cmaps` to `assets/cmaps`. ### [show-borders] -| Property | Type | Required | -| --- | ---- | --- | -| [show-borders] | *boolean* | Optional | +| Property | Type | Required | +| -------------- | --------- | -------- | +| [show-borders] | _boolean_ | Optional | Show page borders + ``` [show-borders]="true" ``` #### (after-load-complete) -| Property | Type | Required | -| --- | ---- | --- | -| (after-load-complete) | *callback* | *Optional* | +| Property | Type | Required | +| --------------------- | ---------- | ---------- | +| (after-load-complete) | _callback_ | _Optional_ | Get PDF information with callback First define callback function "callBackFn" in your controller, + ```typescript callBackFn(pdf: PDFDocumentProxy) { // do anything with "pdf" @@ -366,15 +410,16 @@ callBackFn(pdf: PDFDocumentProxy) { ``` And then use it in your template: + ``` (after-load-complete)="callBackFn($event)" ``` #### (page-rendered) -| Property | Type | Required | -| --- | ---- | --- | -| (page-rendered) | *callback* | *Optional* | +| Property | Type | Required | +| --------------- | ---------- | ---------- | +| (page-rendered) | _callback_ | _Optional_ | Get event when a page is rendered. Called for every page rendered. @@ -394,9 +439,9 @@ And then bind it to ``: #### (pages-initialized) -| Property | Type | Required | -| --- | ---- | --- | -| (pages-initialized) | *callback* | *Optional* | +| Property | Type | Required | +| ------------------- | ---------- | ---------- | +| (pages-initialized) | _callback_ | _Optional_ | Get event when the pages are initialized. @@ -416,9 +461,9 @@ And then bind it to ``: #### (text-layer-rendered) -| Property | Type | Required | -| --- | ---- | --- | -| (text-layer-rendered) | *callback* | *Optional* | +| Property | Type | Required | +| --------------------- | ---------- | ---------- | +| (text-layer-rendered) | _callback_ | _Optional_ | Get event when a text layer is rendered. @@ -438,9 +483,9 @@ And then bind it to ``: #### (error) -| Property | Type | Required | -| --- | ---- | --- | -| (error) | *callback* | *Optional* | +| Property | Type | Required | +| -------- | ---------- | ---------- | +| (error) | _callback_ | _Optional_ | Error handling callback @@ -460,9 +505,9 @@ Then add it to `pdf-component` in component's template #### (on-progress) -| Property | Type | Required | -| --- | ---- | --- | -| (on-progress) | *callback* | *Optional* | +| Property | Type | Required | +| ------------- | ---------- | ---------- | +| (on-progress) | _callback_ | _Optional_ | Loading progress callback - provides progress information `total` and `loaded` bytes. Is called several times during pdf loading phase. @@ -485,7 +530,7 @@ Then add it to `pdf-component` in component's template In your `html` template add `input`: ```html - + ``` and then add `onFileSelected` method to your component: @@ -506,25 +551,26 @@ onFileSelected() { } ``` - ## Set custom path to the worker By default the `worker` is loaded from `cdn.jsdelivr.net`. In your code update `path` to the worker to be for example `/pdf.worker.mjs` + ```typescript (window as any).pdfWorkerSrc = '/pdf.worker.mjs'; ``` -*This should be set before `pdf-viewer` component is rendered.* +_This should be set before `pdf-viewer` component is rendered._ If you ever have a (super rare) edge case where you run in an environment that multiple components are somehow loaded within the same web page, sharing the same window, -but using different versions of pdf.worker, support has been added. You can do the +but using different versions of pdf.worker, support has been added. You can do the above, except that you can append the specific version of pdfjs required and override the -custom path *just for that version*. This way setting the global window var won't conflict. +custom path _just for that version_. This way setting the global window var won't conflict. + ```typescript -(window as any)["pdfWorkerSrc2.14.305"] = '/pdf.worker.mjs'; +(window as any)['pdfWorkerSrc2.14.305'] = '/pdf.worker.mjs'; ``` ## Search in the PDF @@ -533,8 +579,8 @@ Use `eventBus` for the search functionality. In your component's ts file: -* Add reference to `pdf-viewer` component, -* then when needed execute `search()` like this: +- Add reference to `pdf-viewer` component, +- then when needed execute `search()` like this: ```typescript @ViewChild(PdfViewerComponent) private pdfComponent: PdfViewerComponent; @@ -547,9 +593,11 @@ search(stringToSearch: string) { ``` ## Contribute + [See CONTRIBUTING.md](CONTRIBUTING.md) ## Donation + If this project help you reduce time to develop, you can give me a cup of tea :) [![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.me/vadimdez) @@ -557,4 +605,3 @@ If this project help you reduce time to develop, you can give me a cup of tea :) ## License [MIT](https://tldrlegal.com/license/mit-license) © [Vadym Yatsyuk](https://github.com/vadimdez) - diff --git a/src/app/pdf-viewer/pdf-viewer.component.ts b/src/app/pdf-viewer/pdf-viewer.component.ts index 036e26cc..c26d83e7 100644 --- a/src/app/pdf-viewer/pdf-viewer.component.ts +++ b/src/app/pdf-viewer/pdf-viewer.component.ts @@ -13,9 +13,11 @@ import { OnDestroy, ViewChild, AfterViewChecked, - NgZone + NgZone, + AfterViewInit, + inject, } from '@angular/core'; -import { from, fromEvent, Subject } from 'rxjs'; +import { combineLatest, from, fromEvent, Subject } from 'rxjs'; import { debounceTime, filter, takeUntil } from 'rxjs/operators'; import * as PDFJS from 'pdfjs-dist'; import * as PDFJSViewer from 'pdfjs-dist/web/pdf_viewer.mjs'; @@ -30,10 +32,10 @@ import type { PDFDocumentProxy, PDFDocumentLoadingTask, PDFViewerOptions, - ZoomScale + ZoomScale, } from './typings'; import { GlobalWorkerOptions, VerbosityLevel, getDocument } from 'pdfjs-dist'; - +import { ZoomService } from './zoom.service'; if (!isSSR()) { assign(PDFJS, 'verbosity', VerbosityLevel.INFOS); @@ -53,11 +55,10 @@ if (typeof Promise.withResolvers === 'undefined' && window) { }; } - -export enum RenderTextMode { +export const enum RenderTextMode { DISABLED, ENABLED, - ENHANCED + ENHANCED, } @Component({ @@ -67,19 +68,21 @@ export enum RenderTextMode {
`, - styleUrls: ['./pdf-viewer.component.scss'] + styleUrls: ['./pdf-viewer.component.scss'], }) export class PdfViewerComponent - implements OnChanges, OnInit, OnDestroy, AfterViewChecked { + implements OnChanges, OnInit, OnDestroy, AfterViewChecked, AfterViewInit +{ static CSS_UNITS = 96.0 / 72.0; static BORDER_WIDTH = 9; - @ViewChild('pdfViewerContainer') pdfViewerContainer!: ElementRef; + @ViewChild('pdfViewerContainer') + pdfViewerContainer!: ElementRef; - public eventBus!: PDFJSViewer.EventBus; - public pdfLinkService!: PDFJSViewer.PDFLinkService; - public pdfFindController!: PDFJSViewer.PDFFindController; - public pdfViewer!: PDFJSViewer.PDFViewer | PDFJSViewer.PDFSinglePageViewer; + eventBus!: PDFJSViewer.EventBus; + pdfLinkService!: PDFJSViewer.PDFLinkService; + pdfFindController!: PDFJSViewer.PDFFindController; + pdfViewer!: PDFJSViewer.PDFViewer | PDFJSViewer.PDFSinglePageViewer; private isVisible = false; @@ -97,7 +100,7 @@ export class PdfViewerComponent private _originalSize = true; private _pdf: PDFDocumentProxy | undefined; private _page = 1; - private _zoom = 1; + private _zoomScale: ZoomScale = 'page-width'; private _rotation = 0; private _showAll = true; @@ -113,10 +116,13 @@ export class PdfViewerComponent private loadingTask?: PDFDocumentLoadingTask | null; private destroy$ = new Subject(); - @Output('after-load-complete') afterLoadComplete = new EventEmitter(); + @Output('after-load-complete') afterLoadComplete = + new EventEmitter(); @Output('page-rendered') pageRendered = new EventEmitter(); - @Output('pages-initialized') pageInitialized = new EventEmitter(); - @Output('text-layer-rendered') textLayerRendered = new EventEmitter(); + @Output('pages-initialized') pageInitialized = + new EventEmitter(); + @Output('text-layer-rendered') textLayerRendered = + new EventEmitter(); @Output('error') onError = new EventEmitter(); @Output('on-progress') onProgress = new EventEmitter(); @Output() pageChange: EventEmitter = new EventEmitter(true); @@ -173,11 +179,11 @@ export class PdfViewerComponent return; } - this._zoom = value; + this.zoomService.zoom = value; } get zoom() { - return this._zoom; + return this.zoomService.zoom; } @Input('zoom-scale') @@ -219,6 +225,10 @@ export class PdfViewerComponent this._showBorders = Boolean(value); } + @Input() isWheelZoom = true; + @Input() isWheelCtrlZoom = true; + @Input() isOptimizeZoom = true; + static getLinkTarget(type: string) { switch (type) { case 'blank': @@ -236,7 +246,11 @@ export class PdfViewerComponent return null; } - constructor(private element: ElementRef, private ngZone: NgZone) { + private readonly element = inject(ElementRef); + private readonly ngZone = inject(NgZone); + private readonly zoomService = inject(ZoomService); + + constructor() { if (isSSR()) { return; } @@ -244,7 +258,9 @@ export class PdfViewerComponent let pdfWorkerSrc: string; const pdfJsVersion: string = (PDFJS as any).version; - const versionSpecificPdfWorkerUrl: string = (window as any)[`pdfWorkerSrc${pdfJsVersion}`]; + const versionSpecificPdfWorkerUrl: string = (window as any)[ + `pdfWorkerSrc${pdfJsVersion}` + ]; if (versionSpecificPdfWorkerUrl) { pdfWorkerSrc = versionSpecificPdfWorkerUrl; @@ -255,8 +271,7 @@ export class PdfViewerComponent ) { pdfWorkerSrc = (window as any).pdfWorkerSrc; } else { - pdfWorkerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfJsVersion - }/legacy/build/pdf.worker.min.mjs`; + pdfWorkerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfJsVersion}/legacy/build/pdf.worker.min.mjs`; } assign(GlobalWorkerOptions, 'workerSrc', pdfWorkerSrc); @@ -284,6 +299,14 @@ export class PdfViewerComponent } } + ngAfterViewInit(): void { + this.zoomService.initSettings( + this.pdfViewerContainer.nativeElement, + this.isWheelZoom, + this.isWheelCtrlZoom + ); + } + ngOnInit() { this.initialize(); this.setupResizeListener(); @@ -293,6 +316,7 @@ export class PdfViewerComponent this.clear(); this.destroy$.next(); this.loadingTask = null; + this.zoomService.removeListeners(this.pdfViewerContainer.nativeElement); } ngOnChanges(changes: SimpleChanges) { @@ -322,22 +346,27 @@ export class PdfViewerComponent } } - public updateSize() { - from( - this._pdf!.getPage( - this.pdfViewer.currentPageNumber - ) - ) + updateSize(): void { + combineLatest([ + from(this._pdf!.getPage(this.pdfViewer.currentPageNumber)), + this.zoomService.triggerUpdateSize, + ]) .pipe(takeUntil(this.destroy$)) .subscribe({ - next: (page: PDFPageProxy) => { + next: ([page]: [PDFPageProxy, void]) => { + if (this.isOptimizeZoom || this.isWheelZoom) { + this.zoomService.saveScrollPosition( + this.pdfViewerContainer.nativeElement + ); + } + const rotation = this._rotation + page.rotate; const viewportWidth = page.getViewport({ - scale: this._zoom, - rotation + scale: this.zoomService.zoom, + rotation, }).width * PdfViewerComponent.CSS_UNITS; - let scale = this._zoom; + let scale = this.zoomService.zoom; let stickToPage = true; // Scale the document when it shouldn't be in original size or doesn't fit into the viewport @@ -352,13 +381,23 @@ export class PdfViewerComponent } this.pdfViewer.currentScale = scale; + if (stickToPage) - this.pdfViewer.scrollPageIntoView({ pageNumber: page.pageNumber, ignoreDestinationZoom: true }) - } + this.pdfViewer.scrollPageIntoView({ + pageNumber: page.pageNumber, + ignoreDestinationZoom: true, + }); + + if (this.isOptimizeZoom || this.isWheelZoom) { + this.zoomService.restoreScrollPosition( + this.pdfViewerContainer.nativeElement + ); + } + }, }); } - public clear() { + clear(): void { if (this.loadingTask && !this.loadingTask.destroyed) { this.loadingTask.destroy(); } @@ -375,7 +414,9 @@ export class PdfViewerComponent } private getPDFLinkServiceConfig() { - const linkTarget = PdfViewerComponent.getLinkTarget(this._externalLinkTarget); + const linkTarget = PdfViewerComponent.getLinkTarget( + this._externalLinkTarget + ); if (linkTarget) { return { externalLinkTarget: linkTarget }; @@ -422,7 +463,7 @@ export class PdfViewerComponent private initPDFServices() { this.pdfLinkService = new PDFJSViewer.PDFLinkService({ eventBus: this.eventBus, - ...this.getPDFLinkServiceConfig() + ...this.getPDFLinkServiceConfig(), }); this.pdfFindController = new PDFJSViewer.PDFFindController({ eventBus: this.eventBus, @@ -458,7 +499,9 @@ export class PdfViewerComponent if (this._showAll) { this.pdfViewer = new PDFJSViewer.PDFViewer(this.getPDFOptions()); } else { - this.pdfViewer = new PDFJSViewer.PDFSinglePageViewer(this.getPDFOptions()); + this.pdfViewer = new PDFJSViewer.PDFSinglePageViewer( + this.getPDFOptions() + ); } this.pdfLinkService.setViewer(this.pdfViewer); @@ -541,7 +584,7 @@ export class PdfViewerComponent error: (error) => { this.lastLoaded = null; this.onError.emit(error); - } + }, }); } @@ -575,16 +618,20 @@ export class PdfViewerComponent const sub = this.pageInitialized.subscribe(() => { this.updateSize(); sub.unsubscribe(); - }) + }); } else { this.updateSize(); } } private getScale(viewportWidth: number, viewportHeight: number) { - const borderSize = this._showBorders ? 2 * PdfViewerComponent.BORDER_WIDTH : 0; - const pdfContainerWidth = this.pdfViewerContainer.nativeElement.clientWidth - borderSize; - const pdfContainerHeight = this.pdfViewerContainer.nativeElement.clientHeight - borderSize; + const borderSize = this._showBorders + ? 2 * PdfViewerComponent.BORDER_WIDTH + : 0; + const pdfContainerWidth = + this.pdfViewerContainer.nativeElement.clientWidth - borderSize; + const pdfContainerHeight = + this.pdfViewerContainer.nativeElement.clientHeight - borderSize; if ( pdfContainerHeight === 0 || @@ -612,7 +659,7 @@ export class PdfViewerComponent break; } - return (this._zoom * ratio) / PdfViewerComponent.CSS_UNITS; + return (this.zoomService.zoom * ratio) / PdfViewerComponent.CSS_UNITS; } private resetPdfDocument() { diff --git a/src/app/pdf-viewer/zoom.service.ts b/src/app/pdf-viewer/zoom.service.ts new file mode 100644 index 00000000..f24ea7ae --- /dev/null +++ b/src/app/pdf-viewer/zoom.service.ts @@ -0,0 +1,131 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class ZoomService { + private zoomMutex = false; + private ratioX = 0; + private ratioY = 0; + + zoom = 1; + + readonly triggerUpdateSize = new BehaviorSubject(undefined); + + initSettings( + container: HTMLElement, + isWheelZoom: boolean, + isWheelCtrlZoom: boolean + ): void { + container.addEventListener( + 'wheel', + (e: WheelEvent) => { + if ( + isWheelZoom && + ((isWheelCtrlZoom && e.ctrlKey) || !isWheelCtrlZoom) + ) { + e.preventDefault(); // prevent default zoom behavior + this.zoomAtCursor(e); + } + }, + { passive: false } + ); + + container.addEventListener('touchstart', this.onTouchStart, { + passive: false, + }); + container.addEventListener('touchmove', this.onTouchMove, { + passive: false, + }); + container.addEventListener('touchend', this.onTouchEnd); + } + + private zoomAtCursor(event: WheelEvent): void { + const ZOOM_STEP = 0.1; + const delta = event.deltaY < 0 ? 1 + ZOOM_STEP : 1 - ZOOM_STEP; + this.zoom *= delta; + this.zoom = Math.max(0.25, Math.min(this.zoom, 5)); // clamp scale + + this.triggerUpdateSize.next(); + } + + private lastDistance = 0; + private isPinching = false; + + private onTouchStart = (event: TouchEvent) => { + if (event.touches.length === 2) { + this.isPinching = true; + this.lastDistance = this.getDistance(event.touches[0], event.touches[1]); + event.preventDefault(); // prevent default zoom behavior + } + }; + + private onTouchMove = (event: TouchEvent) => { + if (this.isPinching && event.touches.length === 2) { + const currentDistance = this.getDistance( + event.touches[0], + event.touches[1] + ); + + if (this.lastDistance !== 0) { + const scaleChange = currentDistance / this.lastDistance; + + if (Math.abs(scaleChange - 1) > 0.01) { + this.zoom = Math.min(Math.max(this.zoom * scaleChange, 0.5), 4); // clamp between 0.5 and 4 + this.lastDistance = currentDistance; + this.triggerUpdateSize.next(); + } + } + + event.preventDefault(); // prevent scroll or native zoom + } + }; + + private onTouchEnd = (event: TouchEvent) => { + if (event.touches.length < 2) { + this.isPinching = false; + this.lastDistance = 0; + } + }; + + private getDistance(touch1: Touch, touch2: Touch): number { + const dx = touch2.clientX - touch1.clientX; + const dy = touch2.clientY - touch1.clientY; + + return Math.hypot(dx, dy); + } + + saveScrollPosition(container: HTMLElement): void { + if (this.zoomMutex) { + return; + } + + this.zoomMutex = true; + + const el = container; + + const ratioX = el.scrollLeft / el.scrollWidth; + const ratioY = el.scrollTop / el.scrollHeight; + + this.ratioX = ratioX; + this.ratioY = ratioY; + } + + restoreScrollPosition(container: HTMLElement): void { + const el = container; + requestAnimationFrame(() => { + el.scrollLeft = this.ratioX * el.scrollWidth; + el.scrollTop = this.ratioY * el.scrollHeight; + + this.zoomMutex = false; + }); + } + + removeListeners(container: HTMLElement): void { + container.removeEventListener('touchstart', this.onTouchStart); + container.removeEventListener('touchmove', this.onTouchMove); + container.removeEventListener('touchend', this.onTouchEnd); + container.removeEventListener('wheel', this.zoomAtCursor); + } +} From 308550f53b5a898b24a61eebe57efe1832036b53 Mon Sep 17 00:00:00 2001 From: Helias Date: Wed, 11 Jun 2025 21:54:54 +0200 Subject: [PATCH 2/9] docs(README.md): improve doc --- README.md | 44 +++++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0e4d9720..bad1068e 100644 --- a/README.md +++ b/README.md @@ -328,43 +328,61 @@ Turn on or off auto resize. [autoresize]="true" ``` -### [isZoomOptimization] +--- + +### `[isZoomOptimization]` | Property | Type | Required | | -------------------- | --------- | -------- | -| [isZoomOptimization] | _boolean_ | Optional | +| `isZoomOptimization` | _boolean_ | Optional | -Default value: true. It improves the zoom keeping the view in the same position calculating the scrollbars positions before and after the zooming. -It's always true if [isWheelZoom] is enabled. +**Default**: `true` -``` +Improves the zooming experience by maintaining the user's viewport position. It calculates and adjusts the scroll position before and after the zoom to prevent unexpected jumps. + +> Note: This option is always enabled if `[isWheelZoom]` is set to `true`. + +**Example:** + +```html [isZoomOptimization]="true" ``` -### [isWheelZoom] +--- + +### `[isWheelZoom]` | Property | Type | Required | | ------------- | --------- | -------- | -| [isWheelZoom] | _boolean_ | Optional | +| `isWheelZoom` | _boolean_ | Optional | -Default value: true. It allows to zoom via the mouse wheel, optionally the zoom can be performed using or not the Ctrl Key depending on [isWheelCtrlZoom]. +**Default**: `true` -``` +Enables zooming with the mouse wheel. You can control whether zooming requires the `Ctrl` key using `[isWheelCtrlZoom]`. + +```html [isWheelZoom]="true" ``` -### [isWheelCtrlZoom] +--- + +### `[isWheelCtrlZoom]` | Property | Type | Required | | ----------------- | --------- | -------- | -| [isWheelCtrlZoom] | _boolean_ | Optional | +| `isWheelCtrlZoom` | _boolean_ | Optional | -Default value: true. It allows to zoom via the mouse wheel keeping the Ctrl button pressed. If disabled, the mouse wheel will zoom instead of scrolling the bars. +**Default**: `true` -``` +Allows zooming with the mouse wheel only when the `Ctrl` key is pressed. +If set to `false`, the mouse wheel will trigger zooming by default (without requiring `Ctrl`), replacing the typical scroll behavior. + +```html [isWheelCtrlZoom]="true" ``` +--- + #### [c-maps-url] | Property | Type | Required | From fbb5fe7de488b6316bead014fad333dc01b76a91 Mon Sep 17 00:00:00 2001 From: Helias Date: Wed, 11 Jun 2025 22:48:14 +0200 Subject: [PATCH 3/9] test: add unit-tests for zoom service --- src/app/pdf-viewer/zoom.service.spec.ts | 188 ++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src/app/pdf-viewer/zoom.service.spec.ts diff --git a/src/app/pdf-viewer/zoom.service.spec.ts b/src/app/pdf-viewer/zoom.service.spec.ts new file mode 100644 index 00000000..084e628e --- /dev/null +++ b/src/app/pdf-viewer/zoom.service.spec.ts @@ -0,0 +1,188 @@ +import { TestBed } from '@angular/core/testing'; +import { ZoomService } from './zoom.service'; + +fdescribe(ZoomService.name, () => { + let service: ZoomService; + let container: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ZoomService); + + container = document.createElement('div'); + container.style.width = '1000px'; + container.style.height = '1000px'; + container.style.overflow = 'scroll'; + container.innerHTML = '
'; + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('initSettings', () => { + it('should add event listeners to the container', () => { + const addEventListenerSpy = spyOn(container, 'addEventListener'); + + service.initSettings(container, true, true); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'wheel', + jasmine.any(Function), + { passive: false } + ); + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'touchstart', + jasmine.any(Function), + { passive: false } + ); + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'touchmove', + jasmine.any(Function), + { passive: false } + ); + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'touchend', + jasmine.any(Function) + ); + }); + }); + + describe('zoomAtCursor', () => { + it('should zoom in and out based on wheel event', () => { + const wheelEvent = new WheelEvent('wheel', { deltaY: -100 }); + service.zoom = 1; + + service['zoomAtCursor'](wheelEvent); + expect(service.zoom).toBeGreaterThan(1); + + wheelEvent.initEvent('wheel', true, true); + Object.defineProperty(wheelEvent, 'deltaY', { value: 100 }); + service['zoomAtCursor'](wheelEvent); + expect(service.zoom).toBeLessThan(1); + }); + }); + + describe('saveScrollPosition', () => { + it('should save the scroll position ratios', () => { + container.scrollLeft = 500; + container.scrollTop = 500; + + service.saveScrollPosition(container); + + expect(service['ratioX']).toBeCloseTo(0.25); + expect(service['ratioY']).toBeCloseTo(0.25); + }); + }); + + describe('restoreScrollPosition', () => { + it('should restore the scroll position', (done) => { + service['ratioX'] = 0.25; + service['ratioY'] = 0.25; + + service.restoreScrollPosition(container); + + requestAnimationFrame(() => { + expect(container.scrollLeft).toBeCloseTo(500); + expect(container.scrollTop).toBeCloseTo(500); + done(); + }); + }); + }); + + describe('removeListeners', () => { + it('should remove event listeners from the container', () => { + const removeEventListenerSpy = spyOn(container, 'removeEventListener'); + service.removeListeners(container); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'touchstart', + jasmine.any(Function) + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'touchmove', + jasmine.any(Function) + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'touchend', + jasmine.any(Function) + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'wheel', + jasmine.any(Function) + ); + }); + }); + + describe('onTouchStart', () => { + it('should set isPinching to true and calculate distance', () => { + const touch1 = new Touch({ + identifier: 0, + target: container, + clientX: 0, + clientY: 0, + }); + const touch2 = new Touch({ + identifier: 1, + target: container, + clientX: 100, + clientY: 0, + }); + const touchEvent = new TouchEvent('touchstart', { + touches: [touch1, touch2], + }); + + service['onTouchStart'](touchEvent); + + expect(service['isPinching']).toBeTrue(); + expect(service['lastDistance']).toBe(100); + }); + }); + + describe('onTouchMove', () => { + it('should adjust zoom based on pinch gesture', () => { + service['isPinching'] = true; + service['lastDistance'] = 100; + service.zoom = 1; + const touch1 = new Touch({ + identifier: 0, + target: container, + clientX: 0, + clientY: 0, + }); + const touch2 = new Touch({ + identifier: 1, + target: container, + clientX: 200, + clientY: 0, + }); + const touchEvent = new TouchEvent('touchmove', { + touches: [touch1, touch2], + }); + + service['onTouchMove'](touchEvent); + + expect(service.zoom).toBeGreaterThan(1); + expect(service['lastDistance']).toBe(200); + }); + }); + + describe('onTouchEnd', () => { + it('should reset isPinching and lastDistance', () => { + service['isPinching'] = true; + service['lastDistance'] = 100; + + const touchEvent = new TouchEvent('touchend', { touches: [] }); + + service['onTouchEnd'](touchEvent); + + expect(service['isPinching']).toBeFalse(); + expect(service['lastDistance']).toBe(0); + }); + }); +}); From b9b4d6c3f1f400df607f37bc4bf350c3fbaf93b3 Mon Sep 17 00:00:00 2001 From: Helias Date: Wed, 11 Jun 2025 22:53:33 +0200 Subject: [PATCH 4/9] test: fix unit-tests --- .../pdf-viewer/pdf-viewer.component.spec.ts | 41 ++++++++++--------- src/app/pdf-viewer/pdf-viewer.component.ts | 16 ++++---- src/app/pdf-viewer/zoom.service.spec.ts | 2 +- src/app/pdf-viewer/zoom.service.ts | 4 ++ 4 files changed, 34 insertions(+), 29 deletions(-) diff --git a/src/app/pdf-viewer/pdf-viewer.component.spec.ts b/src/app/pdf-viewer/pdf-viewer.component.spec.ts index 003c52c5..4df986b8 100644 --- a/src/app/pdf-viewer/pdf-viewer.component.spec.ts +++ b/src/app/pdf-viewer/pdf-viewer.component.spec.ts @@ -6,15 +6,14 @@ import { PdfViewerModule } from './pdf-viewer.module'; import { GlobalWorkerOptions } from 'pdfjs-dist'; import * as PDFJS from 'pdfjs-dist'; +import { ZoomService } from './zoom.service'; @Component({ - template: ` - - ` + template: ` `, }) -class TestComponent { } +class TestComponent {} -describe('AppComponent', () => { +describe(PdfViewerComponent.name, () => { let pdfViewerFixture: ComponentFixture; let pdfViewer: PdfViewerComponent; let testFixture: ComponentFixture; @@ -23,14 +22,15 @@ describe('AppComponent', () => { function setPdf(numPages: number) { (pdfViewer as any)._pdf = { numPages, - destroy: () => { } + destroy: () => {}, }; } beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [TestComponent], - imports: [PdfViewerModule] + imports: [PdfViewerModule], + providers: [ZoomService], }) .compileComponents() .then(() => { @@ -100,7 +100,7 @@ describe('AppComponent', () => { }); it('should return src', () => { - pdfViewer.cMapsUrl = ""; + pdfViewer.cMapsUrl = ''; pdfViewer.src = src; expect((pdfViewer as any).getDocumentParams()).toBe(src); @@ -152,18 +152,18 @@ describe('AppComponent', () => { beforeEach(() => { (window as any).pdfWorkerSrc = undefined; - (window as any)["pdfWorkerSrc1.2.3"] = undefined; + (window as any)['pdfWorkerSrc1.2.3'] = undefined; (window as any)[`pdfWorkerSrc${curPdfJsVersion}`] = undefined; - }); it('should default to the cdn', () => { pdfViewerFixture = TestBed.createComponent(PdfViewerComponent); pdfViewer = pdfViewerFixture.debugElement.componentInstance; - expect(GlobalWorkerOptions.workerSrc).toBe(`https://cdn.jsdelivr.net/npm/pdfjs-dist@${curPdfJsVersion - }/legacy/build/pdf.worker.min.mjs`); - }) + expect(GlobalWorkerOptions.workerSrc).toBe( + `https://cdn.jsdelivr.net/npm/pdfjs-dist@${curPdfJsVersion}/legacy/build/pdf.worker.min.mjs` + ); + }); it('should support global override', () => { (window as any).pdfWorkerSrc = 'globaloverride'; @@ -172,17 +172,18 @@ describe('AppComponent', () => { pdfViewer = pdfViewerFixture.debugElement.componentInstance; expect(GlobalWorkerOptions.workerSrc).toBe('globaloverride'); - }) + }); it('should default to the cdn when version override does not match version', () => { - (window as any)["pdfWorkerSrc1.2.3"] = 'globaloverride'; + (window as any)['pdfWorkerSrc1.2.3'] = 'globaloverride'; pdfViewerFixture = TestBed.createComponent(PdfViewerComponent); pdfViewer = pdfViewerFixture.debugElement.componentInstance; - expect(GlobalWorkerOptions.workerSrc).toBe(`https://cdn.jsdelivr.net/npm/pdfjs-dist@${curPdfJsVersion - }/legacy/build/pdf.worker.min.mjs`); - }) + expect(GlobalWorkerOptions.workerSrc).toBe( + `https://cdn.jsdelivr.net/npm/pdfjs-dist@${curPdfJsVersion}/legacy/build/pdf.worker.min.mjs` + ); + }); it('should take version override with version match', () => { (window as any)[`pdfWorkerSrc${curPdfJsVersion}`] = 'globaloverride'; @@ -191,6 +192,6 @@ describe('AppComponent', () => { pdfViewer = pdfViewerFixture.debugElement.componentInstance; expect(GlobalWorkerOptions.workerSrc).toBe(`globaloverride`); - }) - }) + }); + }); }); diff --git a/src/app/pdf-viewer/pdf-viewer.component.ts b/src/app/pdf-viewer/pdf-viewer.component.ts index c26d83e7..2a18c66d 100644 --- a/src/app/pdf-viewer/pdf-viewer.component.ts +++ b/src/app/pdf-viewer/pdf-viewer.component.ts @@ -282,7 +282,7 @@ export class PdfViewerComponent return; } - const offset = this.pdfViewerContainer.nativeElement.offsetParent; + const offset = this.pdfViewerContainer?.nativeElement.offsetParent; if (this.isVisible === true && offset == null) { this.isVisible = false; @@ -301,7 +301,7 @@ export class PdfViewerComponent ngAfterViewInit(): void { this.zoomService.initSettings( - this.pdfViewerContainer.nativeElement, + this.pdfViewerContainer?.nativeElement, this.isWheelZoom, this.isWheelCtrlZoom ); @@ -316,7 +316,7 @@ export class PdfViewerComponent this.clear(); this.destroy$.next(); this.loadingTask = null; - this.zoomService.removeListeners(this.pdfViewerContainer.nativeElement); + this.zoomService.removeListeners(this.pdfViewerContainer?.nativeElement); } ngOnChanges(changes: SimpleChanges) { @@ -356,7 +356,7 @@ export class PdfViewerComponent next: ([page]: [PDFPageProxy, void]) => { if (this.isOptimizeZoom || this.isWheelZoom) { this.zoomService.saveScrollPosition( - this.pdfViewerContainer.nativeElement + this.pdfViewerContainer?.nativeElement ); } @@ -373,7 +373,7 @@ export class PdfViewerComponent if ( !this._originalSize || (this._fitToPage && - viewportWidth > this.pdfViewerContainer.nativeElement.clientWidth) + viewportWidth > this.pdfViewerContainer?.nativeElement.clientWidth) ) { const viewPort = page.getViewport({ scale: 1, rotation }); scale = this.getScale(viewPort.width, viewPort.height); @@ -390,7 +390,7 @@ export class PdfViewerComponent if (this.isOptimizeZoom || this.isWheelZoom) { this.zoomService.restoreScrollPosition( - this.pdfViewerContainer.nativeElement + this.pdfViewerContainer?.nativeElement ); } }, @@ -629,9 +629,9 @@ export class PdfViewerComponent ? 2 * PdfViewerComponent.BORDER_WIDTH : 0; const pdfContainerWidth = - this.pdfViewerContainer.nativeElement.clientWidth - borderSize; + this.pdfViewerContainer?.nativeElement.clientWidth - borderSize; const pdfContainerHeight = - this.pdfViewerContainer.nativeElement.clientHeight - borderSize; + this.pdfViewerContainer?.nativeElement.clientHeight - borderSize; if ( pdfContainerHeight === 0 || diff --git a/src/app/pdf-viewer/zoom.service.spec.ts b/src/app/pdf-viewer/zoom.service.spec.ts index 084e628e..c11db139 100644 --- a/src/app/pdf-viewer/zoom.service.spec.ts +++ b/src/app/pdf-viewer/zoom.service.spec.ts @@ -1,7 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { ZoomService } from './zoom.service'; -fdescribe(ZoomService.name, () => { +describe(ZoomService.name, () => { let service: ZoomService; let container: HTMLElement; diff --git a/src/app/pdf-viewer/zoom.service.ts b/src/app/pdf-viewer/zoom.service.ts index f24ea7ae..c6dd82c9 100644 --- a/src/app/pdf-viewer/zoom.service.ts +++ b/src/app/pdf-viewer/zoom.service.ts @@ -123,6 +123,10 @@ export class ZoomService { } removeListeners(container: HTMLElement): void { + if (!container) { + return; + } + container.removeEventListener('touchstart', this.onTouchStart); container.removeEventListener('touchmove', this.onTouchMove); container.removeEventListener('touchend', this.onTouchEnd); From 477fbb9a194d28c89cd8b11e9d94930f9ab0a728 Mon Sep 17 00:00:00 2001 From: Helias Date: Thu, 12 Jun 2025 17:24:37 +0200 Subject: [PATCH 5/9] docs(README.md): add new inputs in the Options list --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index bad1068e..b125fcc6 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,9 @@ export class AppComponent { - [[rotation]](#rotation) - [[zoom]](#zoom) - [[zoom-scale]](#zoom-scale) +- [isZoomOptimization](#isZoomOptimization) +- [isWheelZoom](#isWheelZoom) +- [isWheelCtrlZoom](#isWheelCtrlZoom) - [[original-size]](#original-size) - [[fit-to-page]](#fit-to-page) - [[show-all]](#show-all) From 7fd4f013d91ef5376e92d80c4da13a6aaab04550 Mon Sep 17 00:00:00 2001 From: Helias Date: Fri, 13 Jun 2025 13:48:37 +0200 Subject: [PATCH 6/9] feat(zoom): add inputs minZoom and maxZoom, enable two-way bindings for [(zoom)], fix zoom min/max limits --- README.md | 56 ++++- src/app/app.component.html | 242 ++++++++++++++++----- src/app/app.component.ts | 44 ++-- src/app/pdf-viewer/pdf-viewer.component.ts | 53 +++-- src/app/pdf-viewer/zoom.service.spec.ts | 42 ++++ src/app/pdf-viewer/zoom.service.ts | 42 ++-- 6 files changed, 361 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index b125fcc6..0b52fc43 100644 --- a/README.md +++ b/README.md @@ -106,11 +106,13 @@ export class AppComponent { - [[render-text]](#render-text) - [[render-text-mode]](#render-text-mode) - [[rotation]](#rotation) -- [[zoom]](#zoom) +- [[(zoom)]](#zoom) - [[zoom-scale]](#zoom-scale) -- [isZoomOptimization](#isZoomOptimization) -- [isWheelZoom](#isWheelZoom) -- [isWheelCtrlZoom](#isWheelCtrlZoom) +- [[minZoom]](#minzoom) +- [[maxZoom]](#maxzoom) +- [[isZoomOptimization]](#iszoomoptimization) +- [[isWheelZoom]](#iswheelzoom) +- [[isWheelCtrlZoom]](#iswheelctrlzoom) - [[original-size]](#original-size) - [[fit-to-page]](#fit-to-page) - [[show-all]](#show-all) @@ -256,10 +258,10 @@ _Allowed step is 90 degree, ex. 0, 90, 180_ | -------- | -------- | ---------- | | [zoom] | _number_ | _Optional_ | -Zoom pdf +Zoom pdf, it supports two-way bindings. ``` -[zoom]="0.5" +[(zoom)]="0.5" ``` #### [zoom-scale] @@ -333,7 +335,43 @@ Turn on or off auto resize. --- -### `[isZoomOptimization]` +#### [minZoom] + +| Property | Type | Required | +| --------- | -------- | -------- | +| `minZoom` | _number_ | Optional | + +**Default**: `0`, that means no min value. + +Define a min value for the zoom. + +**Example:** + +```html +[minZoom]="0.1" +``` + +--- + +#### [maxZoom] + +| Property | Type | Required | +| --------- | -------- | -------- | +| `maxZoom` | _number_ | Optional | + +**Default**: `0`, that means no max value. + +Define a max value for the zoom. + +**Example:** + +```html +[maxZoom]="10" +``` + +--- + +#### [isZoomOptimization] | Property | Type | Required | | -------------------- | --------- | -------- | @@ -353,7 +391,7 @@ Improves the zooming experience by maintaining the user's viewport position. It --- -### `[isWheelZoom]` +#### [isWheelZoom] | Property | Type | Required | | ------------- | --------- | -------- | @@ -369,7 +407,7 @@ Enables zooming with the mouse wheel. You can control whether zooming requires t --- -### `[isWheelCtrlZoom]` +#### [isWheelCtrlZoom] | Property | Type | Required | | ----------------- | --------- | -------- | diff --git a/src/app/app.component.html b/src/app/app.component.html index 13b75386..87d9fd28 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,19 +1,37 @@ - - ng2-pdf-viewer + ng2-pdf-viewer - Angular 5+ Component to render PDF + Angular 5+ Component to render PDF - - - Get Started + + + Get Started @@ -21,14 +39,17 @@ Set URL of the PDF file - +
-

- Or render preview by just selecting PDF file (no upload required) -

- +

Or render preview by just selecting PDF file (no upload required)

+
@@ -42,9 +63,7 @@
- - Fit to page - + Fit to page
@@ -76,8 +95,17 @@
- + Input is not a number!
+
+ Min zoom: + + + + Input is not a number! + +
+ +
+ Max zoom: + + + + Input is not a number! + +
@@ -107,39 +181,80 @@
-
+
- + Input is not a number! of {{ pdf.numPages }} - +
-
+
+ Enable Pan +
+ Select an option diff --git a/src/app/app.component.ts b/src/app/app.component.ts index f772d27a..f8950f85 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -39,6 +39,7 @@ export class AppComponent implements OnInit { mobile = false; minZoom = 0.1; maxZoom = 10; + enablePan = false; @ViewChild(PdfViewerComponent) private pdfComponent!: PdfViewerComponent; diff --git a/src/app/pdf-viewer/pdf-viewer.component.spec.ts b/src/app/pdf-viewer/pdf-viewer.component.spec.ts index 4df986b8..90574961 100644 --- a/src/app/pdf-viewer/pdf-viewer.component.spec.ts +++ b/src/app/pdf-viewer/pdf-viewer.component.spec.ts @@ -6,7 +6,7 @@ import { PdfViewerModule } from './pdf-viewer.module'; import { GlobalWorkerOptions } from 'pdfjs-dist'; import * as PDFJS from 'pdfjs-dist'; -import { ZoomService } from './zoom.service'; +import { ZoomService } from './services/zoom.service'; @Component({ template: ` `, diff --git a/src/app/pdf-viewer/pdf-viewer.component.ts b/src/app/pdf-viewer/pdf-viewer.component.ts index dde7a88a..64a1f3bb 100644 --- a/src/app/pdf-viewer/pdf-viewer.component.ts +++ b/src/app/pdf-viewer/pdf-viewer.component.ts @@ -35,8 +35,9 @@ import type { ZoomScale, } from './typings'; import { GlobalWorkerOptions, VerbosityLevel, getDocument } from 'pdfjs-dist'; -import { ZoomService } from './zoom.service'; +import { ZoomService } from './services/zoom.service'; import { DocumentInitParameters } from 'pdfjs-dist/types/src/display/api'; +import { PanService } from './services/pan.service'; if (!isSSR()) { assign(PDFJS, 'verbosity', VerbosityLevel.INFOS); @@ -65,7 +66,14 @@ export const enum RenderTextMode { @Component({ selector: 'pdf-viewer', template: ` -
+
`, @@ -192,7 +200,6 @@ export class PdfViewerComponent set zoomScale(value: ZoomScale) { this._zoomScale = value; } - get zoomScale(): ZoomScale { return this._zoomScale; } @@ -238,6 +245,14 @@ export class PdfViewerComponent this.zoomService.maxZoom = value; this.zoomService.limitZoom(); } + @Input('enablePan') set enablePan(enablePan: boolean) { + this.panService.enablePan = enablePan; + + const cursor = enablePan ? 'grab' : 'default'; + if (this.pdfViewerContainer?.nativeElement) { + this.pdfViewerContainer.nativeElement.style.cursor = cursor; + } + } static getLinkTarget(type: string) { switch (type) { @@ -259,6 +274,7 @@ export class PdfViewerComponent private readonly element = inject(ElementRef); private readonly ngZone = inject(NgZone); private readonly zoomService = inject(ZoomService); + protected readonly panService = inject(PanService); constructor() { if (isSSR()) { diff --git a/src/app/pdf-viewer/services/pan.service.spec.ts b/src/app/pdf-viewer/services/pan.service.spec.ts new file mode 100644 index 00000000..054dd26a --- /dev/null +++ b/src/app/pdf-viewer/services/pan.service.spec.ts @@ -0,0 +1,121 @@ +import { TestBed } from '@angular/core/testing'; +import { PanService } from './pan.service'; + +describe(PanService.name, () => { + let service: PanService; + let container: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(PanService); + + container = document.createElement('div'); + container.style.width = '1000px'; + container.style.height = '1000px'; + container.style.overflow = 'scroll'; + container.innerHTML = '
'; + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('startPan', () => { + it('should not start panning if enablePan is false', () => { + service.enablePan = false; + const event = new MouseEvent('mousedown'); + + service.startPan(event, container); + + expect(service['isPanning']).toBeFalse(); + }); + + it('should start panning and set initial values if enablePan is true', () => { + service.enablePan = true; + const event = new MouseEvent('mousedown', { clientX: 100, clientY: 200 }); + container.scrollLeft = 50; + container.scrollTop = 60; + spyOn(event, 'preventDefault'); + + service.startPan(event, container); + + expect(service['isPanning']).toBeTrue(); + expect(service['mouseStartX']).toBe(100); + expect(service['mouseStartY']).toBe(200); + expect(service['scrollLeft']).toBe(50); + expect(service['scrollTop']).toBe(60); + expect(container.style.cursor).toBe('grabbing'); + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); + + describe('endPan', () => { + it('should not end panning if enablePan is false', () => { + service.enablePan = false; + container.style.cursor = ''; + + service.endPan(container); + + expect(service['isPanning']).toBeFalse(); + expect(container.style.cursor).toBe(''); + }); + + it('should end panning and set cursor if enablePan is true', () => { + service.enablePan = true; + service['isPanning'] = true; + container.style.cursor = 'grabbing'; + + service.endPan(container); + + expect(service['isPanning']).toBeFalse(); + expect(container.style.cursor).toBe('grab'); + }); + }); + + describe('pan', () => { + it('should not pan if enablePan is false', () => { + service.enablePan = false; + const event = new MouseEvent('mousemove', { clientX: 120, clientY: 220 }); + container.scrollLeft = 10; + container.scrollTop = 20; + + service.pan(event, container); + + expect(container.scrollLeft).toBe(10); + expect(container.scrollTop).toBe(20); + }); + + it('should not pan if isPanning is false', () => { + service.enablePan = true; + service['isPanning'] = false; + const event = new MouseEvent('mousemove', { clientX: 120, clientY: 220 }); + container.scrollLeft = 10; + container.scrollTop = 20; + + service.pan(event, container); + + expect(container.scrollLeft).toBe(10); + expect(container.scrollTop).toBe(20); + }); + + it('should update scrollLeft and scrollTop when panning', () => { + service.enablePan = true; + service['isPanning'] = true; + service['mouseStartX'] = 100; + service['mouseStartY'] = 200; + service['scrollLeft'] = 300; + service['scrollTop'] = 400; + const event = new MouseEvent('mousemove', { clientX: 110, clientY: 220 }); + + service.pan(event, container); + + expect(container.scrollLeft).toBe(300 - (110 - 100)); + expect(container.scrollTop).toBe(400 - (220 - 200)); + }); + }); +}); diff --git a/src/app/pdf-viewer/services/pan.service.ts b/src/app/pdf-viewer/services/pan.service.ts new file mode 100644 index 00000000..5de2841e --- /dev/null +++ b/src/app/pdf-viewer/services/pan.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class PanService { + enablePan = false; + + private isPanning = false; + private mouseStartX = 0; + private mouseStartY = 0; + private scrollLeft = 0; + private scrollTop = 0; + + startPan(event: MouseEvent, container: HTMLElement): void { + if (!this.enablePan) { + return; + } + + this.isPanning = true; + + this.mouseStartX = event.clientX; + this.mouseStartY = event.clientY; + this.scrollLeft = container.scrollLeft; + this.scrollTop = container.scrollTop; + + container.style.cursor = 'grabbing'; + + event.preventDefault(); + } + + endPan(container: HTMLElement): void { + if (!this.enablePan) { + return; + } + + this.isPanning = false; + container.style.cursor = 'grab'; + } + + pan(event: MouseEvent, container: HTMLElement): void { + if (!this.enablePan) { + return; + } + + if (!this.isPanning) { + return; + } + + const dx = event.clientX - this.mouseStartX; + const dy = event.clientY - this.mouseStartY; + container.scrollLeft = this.scrollLeft - dx; + container.scrollTop = this.scrollTop - dy; + } +} diff --git a/src/app/pdf-viewer/zoom.service.spec.ts b/src/app/pdf-viewer/services/zoom.service.spec.ts similarity index 96% rename from src/app/pdf-viewer/zoom.service.spec.ts rename to src/app/pdf-viewer/services/zoom.service.spec.ts index 86178d76..1098d740 100644 --- a/src/app/pdf-viewer/zoom.service.spec.ts +++ b/src/app/pdf-viewer/services/zoom.service.spec.ts @@ -75,8 +75,8 @@ describe(ZoomService.name, () => { service.saveScrollPosition(container); - expect(service['ratioX']).toBeCloseTo(0.25); - expect(service['ratioY']).toBeCloseTo(0.25); + expect(service['ratioX']).toBeCloseTo(0.5); + expect(service['ratioY']).toBeCloseTo(0.5); }); }); @@ -88,8 +88,8 @@ describe(ZoomService.name, () => { service.restoreScrollPosition(container); requestAnimationFrame(() => { - expect(container.scrollLeft).toBeCloseTo(500); - expect(container.scrollTop).toBeCloseTo(500); + expect(container.scrollLeft).toBeCloseTo(0); + expect(container.scrollTop).toBeCloseTo(0); done(); }); }); diff --git a/src/app/pdf-viewer/zoom.service.ts b/src/app/pdf-viewer/services/zoom.service.ts similarity index 100% rename from src/app/pdf-viewer/zoom.service.ts rename to src/app/pdf-viewer/services/zoom.service.ts From cfda54daa4455fc95a31ac9d68e6dc632378617e Mon Sep 17 00:00:00 2001 From: Helias Date: Tue, 2 Sep 2025 15:54:04 +0200 Subject: [PATCH 9/9] chore(pdf-viewer.component.ts): one service per pdf instance --- src/app/pdf-viewer/pdf-viewer.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/pdf-viewer/pdf-viewer.component.ts b/src/app/pdf-viewer/pdf-viewer.component.ts index 64a1f3bb..01b5659d 100644 --- a/src/app/pdf-viewer/pdf-viewer.component.ts +++ b/src/app/pdf-viewer/pdf-viewer.component.ts @@ -78,6 +78,7 @@ export const enum RenderTextMode {
`, styleUrls: ['./pdf-viewer.component.scss'], + providers: [ZoomService], }) export class PdfViewerComponent implements OnChanges, OnInit, OnDestroy, AfterViewChecked, AfterViewInit