+
`,
- styleUrls: ['./pdf-viewer.component.scss']
+ styleUrls: ['./pdf-viewer.component.scss'],
+ providers: [ZoomService],
})
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 +110,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 +126,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);
@@ -167,25 +183,25 @@ export class PdfViewerComponent
this._stickToPage = value;
}
- @Input('zoom')
+ @Input()
set zoom(value: number) {
if (value <= 0) {
return;
}
- this._zoom = value;
+ this.zoomService.zoom = value;
+ this.zoomService.limitZoom();
}
-
- get zoom() {
- return this._zoom;
+ get zoom(): number {
+ return this.zoomService.zoom;
}
+ @Output() zoomChange = new EventEmitter();
@Input('zoom-scale')
set zoomScale(value: ZoomScale) {
this._zoomScale = value;
}
-
- get zoomScale() {
+ get zoomScale(): ZoomScale {
return this._zoomScale;
}
@@ -219,6 +235,26 @@ export class PdfViewerComponent
this._showBorders = Boolean(value);
}
+ @Input() isWheelZoom = true;
+ @Input() isWheelCtrlZoom = true;
+ @Input() isOptimizeZoom = true;
+ @Input('minZoom') set minZoom(value: number) {
+ this.zoomService.minZoom = value;
+ this.zoomService.limitZoom();
+ }
+ @Input('maxZoom') set maxZoom(value: number) {
+ 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) {
case 'blank':
@@ -236,7 +272,12 @@ 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);
+ protected readonly panService = inject(PanService);
+
+ constructor() {
if (isSSR()) {
return;
}
@@ -244,7 +285,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 +298,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);
@@ -267,7 +309,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;
@@ -284,18 +326,27 @@ export class PdfViewerComponent
}
}
- ngOnInit() {
+ ngAfterViewInit(): void {
+ this.zoomService.initSettings(
+ this.pdfViewerContainer?.nativeElement,
+ this.isWheelZoom,
+ this.isWheelCtrlZoom
+ );
+ }
+
+ ngOnInit(): void {
this.initialize();
this.setupResizeListener();
}
- ngOnDestroy() {
+ ngOnDestroy(): void {
this.clear();
this.destroy$.next();
this.loadingTask = null;
+ this.zoomService.removeListeners(this.pdfViewerContainer?.nativeElement);
}
- ngOnChanges(changes: SimpleChanges) {
+ ngOnChanges(changes: SimpleChanges): void {
if (isSSR() || !this.isVisible) {
return;
}
@@ -322,29 +373,35 @@ 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
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);
@@ -352,13 +409,25 @@ 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
+ );
+ }
+
+ this.zoomChange.emit(this.zoomService.zoom);
+ },
});
}
- public clear() {
+ clear(): void {
if (this.loadingTask && !this.loadingTask.destroyed) {
this.loadingTask.destroy();
}
@@ -374,8 +443,10 @@ export class PdfViewerComponent
this.pdfFindController && this.pdfFindController.setDocument(null as any);
}
- private getPDFLinkServiceConfig() {
- const linkTarget = PdfViewerComponent.getLinkTarget(this._externalLinkTarget);
+ private getPDFLinkServiceConfig(): {} {
+ const linkTarget = PdfViewerComponent.getLinkTarget(
+ this._externalLinkTarget
+ );
if (linkTarget) {
return { externalLinkTarget: linkTarget };
@@ -384,7 +455,7 @@ export class PdfViewerComponent
return {};
}
- private initEventBus() {
+ private initEventBus(): void {
this.eventBus = createEventBus(PDFJSViewer, this.destroy$);
fromEvent(this.eventBus, 'pagerendered')
@@ -419,10 +490,10 @@ export class PdfViewerComponent
});
}
- private initPDFServices() {
+ private initPDFServices(): void {
this.pdfLinkService = new PDFJSViewer.PDFLinkService({
eventBus: this.eventBus,
- ...this.getPDFLinkServiceConfig()
+ ...this.getPDFLinkServiceConfig(),
});
this.pdfFindController = new PDFJSViewer.PDFFindController({
eventBus: this.eventBus,
@@ -446,7 +517,7 @@ export class PdfViewerComponent
};
}
- private setupViewer() {
+ private setupViewer(): void {
if (this.pdfViewer) {
this.pdfViewer.setDocument(null as any);
}
@@ -458,7 +529,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);
@@ -477,7 +550,11 @@ export class PdfViewerComponent
return page;
}
- private getDocumentParams() {
+ private getDocumentParams():
+ | string
+ | Uint8Array
+ | DocumentInitParameters
+ | undefined {
const srcType = typeof this.src;
if (!this._cMapsUrl) {
@@ -504,7 +581,7 @@ export class PdfViewerComponent
return params;
}
- private loadPDF() {
+ private loadPDF(): void {
if (!this.src) {
return;
}
@@ -541,17 +618,17 @@ export class PdfViewerComponent
error: (error) => {
this.lastLoaded = null;
this.onError.emit(error);
- }
+ },
});
}
- private update() {
+ private update(): void {
this.page = this._page;
this.render();
}
- private render() {
+ private render(): void {
this._page = this.getValidPageNumber(this._page);
if (
@@ -575,16 +652,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;
+ private getScale(viewportWidth: number, viewportHeight: number): number {
+ 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,10 +693,10 @@ export class PdfViewerComponent
break;
}
- return (this._zoom * ratio) / PdfViewerComponent.CSS_UNITS;
+ return (this.zoomService.zoom * ratio) / PdfViewerComponent.CSS_UNITS;
}
- private resetPdfDocument() {
+ private resetPdfDocument(): void {
this.pdfLinkService.setDocument(this._pdf, null);
this.pdfFindController.setDocument(this._pdf!);
this.pdfViewer.setDocument(this._pdf!);
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/services/zoom.service.spec.ts b/src/app/pdf-viewer/services/zoom.service.spec.ts
new file mode 100644
index 00000000..1098d740
--- /dev/null
+++ b/src/app/pdf-viewer/services/zoom.service.spec.ts
@@ -0,0 +1,230 @@
+import { TestBed } from '@angular/core/testing';
+import { ZoomService } from './zoom.service';
+
+describe(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.5);
+ expect(service['ratioY']).toBeCloseTo(0.5);
+ });
+ });
+
+ 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(0);
+ expect(container.scrollTop).toBeCloseTo(0);
+ 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);
+ });
+ });
+
+ describe('limitZoom', () => {
+ it('should not change zoom if minZoom and maxZoom are 0', () => {
+ service.zoom = 2;
+ service.minZoom = 0;
+ service.maxZoom = 0;
+
+ service.limitZoom();
+
+ expect(service.zoom).toBe(2);
+ });
+
+ it('should set zoom to maxZoom if zoom exceeds maxZoom', () => {
+ service.zoom = 3;
+ service.maxZoom = 2;
+ service.minZoom = 0;
+
+ service.limitZoom();
+
+ expect(service.zoom).toBe(2);
+ });
+
+ it('should set zoom to minZoom if zoom is below minZoom', () => {
+ service.zoom = 0.5;
+ service.minZoom = 1;
+ service.maxZoom = 0;
+
+ service.limitZoom();
+
+ expect(service.zoom).toBe(1);
+ });
+
+ it('should keep zoom within minZoom and maxZoom bounds', () => {
+ service.zoom = 1.5;
+ service.minZoom = 1;
+ service.maxZoom = 2;
+
+ service.limitZoom();
+
+ expect(service.zoom).toBe(1.5);
+ });
+ });
+});
diff --git a/src/app/pdf-viewer/services/zoom.service.ts b/src/app/pdf-viewer/services/zoom.service.ts
new file mode 100644
index 00000000..205ff42f
--- /dev/null
+++ b/src/app/pdf-viewer/services/zoom.service.ts
@@ -0,0 +1,157 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject } from 'rxjs';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class ZoomService {
+ private zoomMutex = false;
+ zoom = 1;
+ maxZoom = 0;
+ minZoom = 0;
+
+ private lastDistance = 0;
+ private isPinching = false;
+ private ratioX = 0;
+ private ratioY = 0;
+
+ 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);
+ }
+
+ limitZoom(): void {
+ if (this.minZoom === 0 && this.maxZoom === 0) {
+ return;
+ }
+
+ if (this.maxZoom > 0 && this.zoom > this.maxZoom) {
+ this.zoom = this.maxZoom;
+ }
+
+ if (this.minZoom > 0 && this.zoom < this.minZoom) {
+ this.zoom = this.minZoom;
+ }
+ }
+
+ 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.limitZoom();
+
+ this.triggerUpdateSize.next();
+ }
+
+ private onTouchStart = (event: TouchEvent): void => {
+ if (event.touches.length === 2) {
+ event.preventDefault(); // prevent default zoom behavior
+
+ this.isPinching = true;
+ this.lastDistance = this.getDistance(event.touches[0], event.touches[1]);
+ }
+ };
+
+ private onTouchMove = (event: TouchEvent): void => {
+ if (this.isPinching && event.touches.length === 2) {
+ event.preventDefault(); // prevent scroll or native zoom
+
+ const currentDistance = this.getDistance(
+ event.touches[0],
+ event.touches[1]
+ );
+
+ if (this.lastDistance !== 0) {
+ const scaleChange = currentDistance / this.lastDistance;
+
+ this.zoom *= scaleChange;
+ this.limitZoom();
+ this.lastDistance = currentDistance;
+ this.triggerUpdateSize.next();
+ }
+ }
+ };
+
+ private onTouchEnd = (event: TouchEvent): void => {
+ if (event.touches.length < 2) {
+ event.preventDefault(); // prevent scroll or native zoom
+
+ 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 { scrollLeft, scrollTop, scrollWidth, scrollHeight } = container;
+ const { width, height } = container.getBoundingClientRect();
+
+ const centerX = scrollLeft + width / 2;
+ const centerY = scrollTop + height / 2;
+
+ this.ratioX = centerX / scrollWidth;
+ this.ratioY = centerY / scrollHeight;
+ }
+
+ restoreScrollPosition(container: HTMLElement): void {
+ requestAnimationFrame(() => {
+ const { width, height } = container.getBoundingClientRect();
+
+ const centerX = this.ratioX * container.scrollWidth;
+ const centerY = this.ratioY * container.scrollHeight;
+
+ container.scrollLeft = centerX - width / 2;
+ container.scrollTop = centerY - height / 2;
+
+ this.zoomMutex = false;
+ });
+ }
+
+ removeListeners(container: HTMLElement): void {
+ if (!container) {
+ return;
+ }
+
+ container.removeEventListener('touchstart', this.onTouchStart);
+ container.removeEventListener('touchmove', this.onTouchMove);
+ container.removeEventListener('touchend', this.onTouchEnd);
+ container.removeEventListener('wheel', this.zoomAtCursor);
+ }
+}