AAE-34298 Move viewer to separate entry point

This commit is contained in:
Wojciech Duda
2025-05-28 15:19:19 +02:00
parent 5bbb5c5716
commit 4bcfc32d16
79 changed files with 36 additions and 33 deletions

View File

@@ -0,0 +1,23 @@
<div mat-dialog-title>
<h3>{{ 'ADF_VIEWER.NON_RESPONSIVE_DIALOG.HEADER' | translate }}</h3>
</div>
<mat-dialog-content>
{{ 'ADF_VIEWER.NON_RESPONSIVE_DIALOG.LABEL' | translate }}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button
mat-button
id="downloadButton"
[attr.aria-label]="'ADF_VIEWER.ACTIONS.DOWNLOAD' | translate"
[mat-dialog-close]="DownloadPromptActions.DOWNLOAD">
{{ 'ADF_VIEWER.ACTIONS.DOWNLOAD' | translate }}
</button>
<button
mat-button
id="waitButton"
color="primary"
[attr.aria-label]="'ADF_VIEWER.ACTIONS.WAIT' | translate"
[mat-dialog-close]="DownloadPromptActions.WAIT">
{{ 'ADF_VIEWER.ACTIONS.WAIT' | translate }}
</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,64 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialogRef } from '@angular/material/dialog';
import { CoreTestingModule, UnitTestingUtils } from '../../../testing';
import { DownloadPromptActions } from '../../models/download-prompt.actions';
import { DownloadPromptDialogComponent } from './download-prompt-dialog.component';
const mockDialog = {
close: jasmine.createSpy('close')
};
describe('DownloadPromptDialogComponent', () => {
let matDialogRef: MatDialogRef<DownloadPromptDialogComponent>;
let fixture: ComponentFixture<DownloadPromptDialogComponent>;
let testingUtils: UnitTestingUtils;
const clickButton = (buttonId: string) => testingUtils.clickByCSS(buttonId);
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CoreTestingModule, DownloadPromptDialogComponent],
providers: [{ provide: MatDialogRef, useValue: mockDialog }]
});
matDialogRef = TestBed.inject(MatDialogRef);
fixture = TestBed.createComponent(DownloadPromptDialogComponent);
testingUtils = new UnitTestingUtils(fixture.debugElement);
fixture.detectChanges();
});
it('should emit DownloadPromptActions.WAIT and close dialog when clicking on the wait button', async () => {
clickButton('#waitButton');
fixture.detectChanges();
await fixture.whenStable();
expect(matDialogRef.close).toHaveBeenCalledWith(DownloadPromptActions.WAIT);
});
it('should emit DownloadPromptActions.DOWNLOAD and close dialog when clicking on the download button', async () => {
clickButton('#downloadButton');
fixture.detectChanges();
await fixture.whenStable();
expect(matDialogRef.close).toHaveBeenCalledWith(DownloadPromptActions.DOWNLOAD);
});
});

View File

@@ -0,0 +1,32 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { DownloadPromptActions } from '../../models/download-prompt.actions';
@Component({
selector: 'adf-download-prompt-dialog',
standalone: true,
imports: [MatDialogModule, TranslateModule, MatButtonModule],
templateUrl: './download-prompt-dialog.component.html'
})
export class DownloadPromptDialogComponent {
DownloadPromptActions = DownloadPromptActions;
}

View File

@@ -0,0 +1,80 @@
<div id="adf-image-container"
(keydown)="onKeyDown($event)"
class="adf-image-container"
tabindex="0"
role="img"
[attr.aria-label]="fileName"
data-automation-id="adf-image-container">
<img #image id="viewer-image"
[src]="urlFile"
[alt]="fileName"
(load)="imageLoaded.emit()"
(error)="onImageError()" />
</div>
<div class="adf-image-viewer__toolbar" *ngIf="showToolbar">
<adf-toolbar class="adf-main-toolbar">
<button id="viewer-zoom-out-button"
title="{{ 'ADF_VIEWER.ARIA.ZOOM_OUT' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.ZOOM_OUT' | translate }}"
mat-icon-button
(click)="zoomOut()">
<mat-icon>zoom_out</mat-icon>
</button>
<div class="adf-image-viewer__toolbar-page-scale" data-automation-id="adf-page-scale">
{{ currentScaleText }}
</div>
<button id="viewer-zoom-in-button"
mat-icon-button
title="{{ 'ADF_VIEWER.ARIA.ZOOM_IN' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.ZOOM_IN' | translate }}"
(click)="zoomIn()">
<mat-icon>zoom_in</mat-icon>
</button>
<button *ngIf="!readOnly && allowedEditActions.rotate" id="viewer-rotate-button"
title="{{ 'ADF_VIEWER.ARIA.ROTATE' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.ROTATE' | translate }}"
mat-icon-button
(click)="rotateImage()">
<mat-icon>rotate_left</mat-icon>
</button>
<button *ngIf="!readOnly && allowedEditActions.crop" id="viewer-crop-button"
title="{{ 'ADF_VIEWER.ARIA.CROP' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.CROP' | translate }}"
mat-icon-button
(click)="cropImage()">
<mat-icon>crop</mat-icon>
</button>
<button id="viewer-reset-button"
title="{{ 'ADF_VIEWER.ARIA.RESET' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.RESET' | translate }}"
mat-icon-button
(click)="reset()">
<mat-icon>zoom_out_map</mat-icon>
</button>
</adf-toolbar>
<adf-toolbar class="adf-secondary-toolbar" *ngIf="isEditing && !readOnly">
<button id="viewer-cancel-button"
title="{{ 'ADF_VIEWER.ARIA.CANCEL' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.CANCEL' | translate }}"
mat-icon-button
(click)="reset()">
<mat-icon>close</mat-icon>
</button>
<button id="viewer-save-button"
title="{{ 'ADF_VIEWER.ARIA.SAVE' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.SAVE' | translate }}"
mat-icon-button
(click)="save()">
<mat-icon>check</mat-icon>
</button>
</adf-toolbar>
</div>

View File

@@ -0,0 +1,66 @@
@use 'mat-selectors' as ms;
.adf-image-viewer {
width: 100%;
.adf-image-container {
&:focus {
outline-offset: -1px;
outline: 1px solid var(--theme-accent-color-a200);
}
display: flex;
height: 90vh;
align-items: center;
justify-content: center;
img {
max-height: 100%;
max-width: 100%;
}
/* query for Microsoft IE 11 */
@media screen and (-ms-high-contrast: active), screen and (-ms-high-contrast: none) {
img {
height: 100%;
}
}
}
&__toolbar {
position: absolute;
bottom: 5px;
left: 50%;
transform: translateX(-50%);
.adf-toolbar #{ms.$mat-toolbar} {
max-height: 48px;
background-color: var(--adf-theme-background-card-color);
border-width: 0;
border-radius: 2px;
/* stylelint-disable-next-line */
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.24), 0 0 2px 0 rgba(0, 0, 0, 0.12);
}
.adf-main-toolbar {
display: inline-block;
}
.adf-secondary-toolbar {
display: inline-block;
margin-left: 10px;
}
&-page-scale {
cursor: default;
width: 79px;
height: 24px;
font-size: var(--theme-body-1-font-size);
border: 1px solid var(--adf-theme-foreground-text-color-007);
text-align: center;
line-height: 24px;
margin-left: 4px;
margin-right: 4px;
}
}
}

View File

@@ -0,0 +1,384 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { SimpleChange } from '@angular/core';
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { AppConfigService } from '../../../app-config';
import { UrlService } from '../../../common';
import { CoreTestingModule, UnitTestingUtils } from '../../../testing';
import { ImgViewerComponent } from './img-viewer.component';
describe('Test Img viewer component ', () => {
let component: ImgViewerComponent;
let urlService: UrlService;
let fixture: ComponentFixture<ImgViewerComponent>;
let testingUtils: UnitTestingUtils;
const createFakeBlob = () => {
const data = atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
return new Blob([data], { type: 'image/png' });
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CoreTestingModule]
});
});
describe('Zoom customization', () => {
beforeEach(() => {
urlService = TestBed.inject(UrlService);
fixture = TestBed.createComponent(ImgViewerComponent);
testingUtils = new UnitTestingUtils(fixture.debugElement);
component = fixture.componentInstance;
component.urlFile = 'fake-url-file.png';
fixture.detectChanges();
});
describe('default value', () => {
it('should use default zoom if is not present a custom zoom in the app.config', () => {
fixture.detectChanges();
expect(component.scale).toBe(1.0);
});
});
describe('custom value', () => {
beforeEach(() => {
const appConfig: AppConfigService = TestBed.inject(AppConfigService);
appConfig.config['adf-viewer-render.image-viewer-scaling'] = 70;
component.initializeScaling();
});
it('should use the custom zoom if it is present in the app.config', (done) => {
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(component.scale).toBe(0.7);
done();
});
});
});
});
describe('Url', () => {
beforeEach(() => {
urlService = TestBed.inject(UrlService);
fixture = TestBed.createComponent(ImgViewerComponent);
component = fixture.componentInstance;
component.urlFile =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==';
fixture.detectChanges();
fixture.componentInstance.ngAfterViewInit();
component.ngAfterViewInit();
fixture.detectChanges();
});
it('should display current scale as percent string', () => {
component.scale = 0.5;
expect(component.currentScaleText).toBe('50%');
component.scale = 1.0;
expect(component.currentScaleText).toBe('100%');
});
it('should define cropper after init', () => {
fixture.componentInstance.ngAfterViewInit();
expect(component.cropper).toBeDefined();
});
});
describe('Blob', () => {
beforeEach(() => {
urlService = TestBed.inject(UrlService);
fixture = TestBed.createComponent(ImgViewerComponent);
testingUtils.setDebugElement(fixture.debugElement);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should thrown an error if no url or blob are passed', () => {
const change = new SimpleChange(null, null, true);
expect(() => {
component.ngOnChanges({ blobFile: change, urlFile: change });
}).toThrow(new Error('Attribute urlFile or blobFile is required'));
});
it('should not thrown an error if url is passed ', () => {
component.urlFile = 'fake-url';
expect(() => {
component.ngOnChanges(null);
}).not.toThrow(new Error('Attribute urlFile or blobFile is required'));
});
it('should present file name in the alt attribute', () => {
component.fileName = 'fake-name';
fixture.detectChanges();
expect(testingUtils.getByCSS('#viewer-image').nativeElement.getAttribute('alt')).toEqual('fake-name');
});
it('should call replace on cropper with new url if blobFile is null', () => {
component.urlFile = 'fake-url';
spyOn(component.cropper, 'replace').and.stub();
const urlFile = new SimpleChange('fake-url', 'fake-url-2', false);
fixture.detectChanges();
component.ngOnChanges({ urlFile });
expect(component.cropper.replace).toHaveBeenCalledWith('fake-url-2');
});
it('should not thrown an error if blob is passed ', () => {
const blob = createFakeBlob();
spyOn(urlService, 'createTrustedUrl').and.returnValue('fake-blob-url');
const change = new SimpleChange(null, blob, true);
expect(() => {
component.ngOnChanges({ blobFile: change });
}).not.toThrow(new Error('Attribute urlFile or blobFile is required'));
expect(component.urlFile).toEqual('fake-blob-url');
});
});
describe('toolbar actions', () => {
beforeEach(() => {
fixture = TestBed.createComponent(ImgViewerComponent);
component = fixture.componentInstance;
testingUtils = new UnitTestingUtils(fixture.debugElement);
component.blobFile = createFakeBlob();
const change = new SimpleChange(null, component.blobFile, true);
component.ngOnChanges({ blobFile: change });
fixture.detectChanges();
});
it('should update scales on zoom in', fakeAsync(() => {
spyOn(component, 'zoomIn').and.callThrough();
spyOn(component.cropper, 'zoom');
component.scale = 1.0;
tick();
component.zoomIn();
expect(component.scale).toBe(1.2);
expect(component.cropper.zoom).toHaveBeenCalledWith(0.2);
component.zoomIn();
expect(component.scale).toBe(1.4);
expect(component.cropper.zoom).toHaveBeenCalledWith(0.2);
}));
it('should update scales on zoom out', fakeAsync(() => {
spyOn(component, 'zoomOut').and.callThrough();
spyOn(component.cropper, 'zoom');
component.scale = 1.0;
tick();
component.zoomOut();
expect(component.scale).toBe(0.8);
expect(component.cropper.zoom).toHaveBeenCalledWith(-0.2);
component.zoomOut();
expect(component.scale).toBe(0.6);
expect(component.cropper.zoom).toHaveBeenCalledWith(-0.2);
}));
it('should not zoom out past 20%', fakeAsync(() => {
component.scale = 0.2;
tick();
component.zoomOut();
component.zoomOut();
component.zoomOut();
expect(component.scale).toBe(0.2);
}));
it('should show rotate button if not in read only mode', () => {
component.readOnly = false;
fixture.detectChanges();
expect(testingUtils.getByCSS('#viewer-rotate-button')).not.toEqual(null);
});
it('should not show rotate button by default', () => {
expect(testingUtils.getByCSS('#viewer-rotate-button')).toEqual(null);
});
it('should not show crop button by default', () => {
expect(testingUtils.getByCSS('#viewer-crop-button')).toEqual(null);
});
it('should start cropping when clicking the crop button', fakeAsync(() => {
component.readOnly = false;
spyOn(component, 'cropImage').and.callThrough();
spyOn(component.cropper, 'crop');
spyOn(component.cropper, 'setDragMode');
fixture.detectChanges();
testingUtils.clickByCSS('#viewer-crop-button');
tick();
expect(component.cropImage).toHaveBeenCalled();
expect(component.cropper.crop).toHaveBeenCalled();
expect(component.cropper.setDragMode).toHaveBeenCalledWith('crop');
}));
it('should rotate image by -90 degrees on button click', fakeAsync(() => {
component.readOnly = false;
spyOn(component, 'rotateImage').and.callThrough();
spyOn(component.cropper, 'rotate');
fixture.detectChanges();
testingUtils.clickByCSS('#viewer-rotate-button');
tick();
expect(component.rotateImage).toHaveBeenCalled();
expect(component.cropper.rotate).toHaveBeenCalledWith(-90);
}));
it('should display the second toolbar when in editing and not in read only mode', fakeAsync(() => {
component.readOnly = false;
component.isEditing = true;
fixture.detectChanges();
expect(testingUtils.getByCSS('.adf-secondary-toolbar')).not.toEqual(null);
}));
it('should not display the second toolbar when in read only mode', () => {
component.readOnly = true;
fixture.detectChanges();
expect(testingUtils.getByCSS('.adf-secondary-toolbar')).toEqual(null);
});
it('should not display the second toolbar when not in editing', () => {
component.readOnly = true;
component.isEditing = false;
fixture.detectChanges();
expect(testingUtils.getByCSS('.adf-secondary-toolbar')).toEqual(null);
});
it('should display second toolbar in edit mode', fakeAsync(() => {
component.readOnly = false;
component.isEditing = true;
fixture.detectChanges();
expect(testingUtils.getByCSS('.adf-secondary-toolbar')).not.toEqual(null);
expect(testingUtils.getByCSS('#viewer-cancel-button')).not.toEqual(null);
expect(testingUtils.getByCSS('#viewer-save-button')).not.toEqual(null);
}));
it('should not be in editing mode by default', () => {
component.readOnly = false;
expect(component.isEditing).toEqual(false);
});
it('should get in editing mode when the image gets rotated', () => {
component.readOnly = false;
component.rotateImage();
expect(component.isEditing).toEqual(true);
});
it('should get in editing mode when the image gets cropped', () => {
component.readOnly = false;
component.cropImage();
expect(component.isEditing).toEqual(true);
});
it('should reset the scale and hide second toolbar', fakeAsync(() => {
component.readOnly = false;
component.isEditing = true;
spyOn(component, 'reset').and.callThrough();
spyOn(component, 'updateCanvasContainer');
spyOn(component.cropper, 'reset');
spyOn(component.cropper, 'clear');
spyOn(component.cropper, 'zoomTo');
fixture.detectChanges();
testingUtils.clickByCSS('#viewer-cancel-button');
tick();
expect(component.reset).toHaveBeenCalled();
expect(component.scale).toEqual(1.0);
expect(component.isEditing).toEqual(false);
expect(component.cropper.reset).toHaveBeenCalled();
expect(component.cropper.clear).toHaveBeenCalled();
expect(component.updateCanvasContainer).toHaveBeenCalled();
}));
it('should save when clicked on toolbar button', fakeAsync(() => {
component.readOnly = false;
component.isEditing = true;
const canvasMock = document.createElement('canvas');
spyOn(component.isSaving, 'emit');
spyOn(component, 'save').and.callThrough();
spyOn(component.cropper, 'getCroppedCanvas').and.returnValue(canvasMock);
spyOn(component.cropper.getCroppedCanvas(), 'toBlob').and.callFake(() => component.isSaving.emit(false));
fixture.detectChanges();
testingUtils.clickByCSS('#viewer-save-button');
tick();
expect(component.save).toHaveBeenCalled();
expect(component.isSaving.emit).toHaveBeenCalledWith(true);
expect(component.isSaving.emit).toHaveBeenCalledWith(false);
}));
it('should reset the viewer after going to full screen mode', () => {
Object.defineProperty(document, 'fullscreenElement', {
value: true
});
spyOn(component, 'reset');
document.dispatchEvent(new Event('fullscreenchange'));
expect(component.reset).toHaveBeenCalled();
});
});
describe('allowedEditActions', () => {
beforeEach(() => {
fixture = TestBed.createComponent(ImgViewerComponent);
testingUtils.setDebugElement(fixture.debugElement);
component = fixture.componentInstance;
});
it('should conditionally display rotate and crop buttons based on allowedEditActions', () => {
component.readOnly = false;
component.allowedEditActions = { rotate: true, crop: true };
fixture.detectChanges();
// Check both buttons are visible when allowed
expect(testingUtils.getByCSS('#viewer-rotate-button')).not.toBeNull('Rotate button should be visible when allowed');
expect(testingUtils.getByCSS('#viewer-crop-button')).not.toBeNull('Crop button should be visible when allowed');
// Change allowedEditActions to disallow both actions
component.allowedEditActions = { rotate: false, crop: false };
fixture.detectChanges();
// Check both buttons are not visible when not allowed
expect(testingUtils.getByCSS('#viewer-rotate-button')).toBeNull('Rotate button should not be visible when disallowed');
expect(testingUtils.getByCSS('#viewer-crop-button')).toBeNull('Crop button should not be visible when disallowed');
});
});
});

View File

@@ -0,0 +1,247 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NgIf } from '@angular/common';
import {
AfterViewInit,
Component,
ElementRef,
EventEmitter,
HostListener,
Input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import Cropper from 'cropperjs';
import { AppConfigService, UrlService, ToolbarComponent } from '@alfresco/adf-core';
@Component({
selector: 'adf-img-viewer',
standalone: true,
templateUrl: './img-viewer.component.html',
styleUrls: ['./img-viewer.component.scss'],
host: { class: 'adf-image-viewer' },
imports: [ToolbarComponent, TranslateModule, MatIconModule, MatButtonModule, NgIf],
encapsulation: ViewEncapsulation.None
})
export class ImgViewerComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input()
showToolbar = true;
@Input()
readOnly = true;
@Input()
allowedEditActions: { [key: string]: boolean } = {
rotate: true,
crop: true
};
@Input()
urlFile: string;
@Input()
blobFile: Blob;
@Input()
fileName: string;
// eslint-disable-next-line @angular-eslint/no-output-native
@Output()
error = new EventEmitter<any>();
// eslint-disable-next-line @angular-eslint/no-output-native
@Output()
submit = new EventEmitter<any>();
@Output()
isSaving = new EventEmitter<boolean>();
@Output()
imageLoaded = new EventEmitter<void>();
@ViewChild('image', { static: false })
imageElement: ElementRef;
@HostListener('document:keydown', ['$event'])
onKeyDown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
this.cropper.move(-3, 0);
break;
case 'ArrowUp':
event.preventDefault();
this.cropper.move(0, -3);
break;
case 'ArrowRight':
event.preventDefault();
this.cropper.move(3, 0);
break;
case 'ArrowDown':
event.preventDefault();
this.cropper.move(0, 3);
break;
case 'i':
this.zoomIn();
break;
case 'o':
this.zoomOut();
break;
case 'r':
this.rotateImage();
break;
default:
}
}
@HostListener('document:fullscreenchange')
fullScreenChangeHandler() {
if (document.fullscreenElement) {
this.reset();
}
}
scale: number = 1.0;
cropper: Cropper;
isEditing: boolean = false;
get currentScaleText(): string {
return Math.round(this.scale * 100) + '%';
}
constructor(private appConfigService: AppConfigService, private urlService: UrlService) {
this.initializeScaling();
}
ngOnChanges(changes: SimpleChanges) {
const blobFile = changes['blobFile'];
if (blobFile?.currentValue) {
this.urlFile = this.urlService.createTrustedUrl(this.blobFile);
return;
}
if (!changes['urlFile'].firstChange) {
if (changes['urlFile'].previousValue !== changes['urlFile'].currentValue) {
this.cropper.replace(changes['urlFile'].currentValue);
}
}
if (!this.urlFile && !this.blobFile) {
throw new Error('Attribute urlFile or blobFile is required');
}
}
ngAfterViewInit() {
this.cropper = new Cropper(this.imageElement.nativeElement, {
autoCrop: false,
checkOrientation: false,
dragMode: 'move',
background: false,
scalable: true,
zoomOnWheel: true,
toggleDragModeOnDblclick: false,
viewMode: 1,
checkCrossOrigin: false,
ready: () => {
this.updateCanvasContainer();
}
});
}
ngOnDestroy() {
this.cropper.destroy();
}
initializeScaling() {
const scaling = this.appConfigService.get<number>('adf-viewer-render.image-viewer-scaling', undefined) / 100;
if (scaling) {
this.scale = scaling;
}
}
zoomIn() {
this.cropper.zoom(0.2);
this.scale = +(this.scale + 0.2).toFixed(1);
}
zoomOut() {
if (this.scale > 0.2) {
this.cropper.zoom(-0.2);
this.scale = +(this.scale - 0.2).toFixed(1);
}
}
rotateImage() {
this.isEditing = true;
this.cropper.rotate(-90);
}
cropImage() {
this.isEditing = true;
this.cropper.setDragMode('crop');
this.cropper.crop();
}
save() {
this.isSaving.emit(true);
this.isEditing = false;
this.cropper.setDragMode('move');
this.cropper.getCroppedCanvas().toBlob((blob) => {
this.submit.emit(blob);
this.cropper.replace(this.cropper.getCroppedCanvas().toDataURL());
this.cropper.clear();
this.isSaving.emit(false);
});
}
reset() {
this.isEditing = false;
this.cropper.clear();
this.cropper.reset();
this.cropper.setDragMode('move');
this.scale = 1.0;
this.updateCanvasContainer();
}
updateCanvasContainer() {
if (this.imageElement.nativeElement.width < this.cropper.getContainerData().width) {
const width = this.imageElement.nativeElement.width;
const height = this.imageElement.nativeElement.height;
const top = (this.cropper.getContainerData().height - this.imageElement.nativeElement.height) / 2;
const left = (this.cropper.getContainerData().width - this.imageElement.nativeElement.width) / 2;
this.cropper.setCanvasData({
width,
height,
top,
left
});
}
}
onImageError() {
this.error.emit();
}
}

View File

@@ -0,0 +1,11 @@
<video controls class="adf-video-player" (canplay)="canPlay.emit()"
[ngClass]="{ 'adf-audio-file': mimeType && mimeType.startsWith('audio') }">
<source [src]="urlFile"
[type]="mimeType"
(error)="onMediaPlayerError($event)" />
<track *ngFor="let track of tracks"
[kind]="track.kind"
[label]="track.label"
[srclang]="track.srclang"
[src]="track.src" />
</video>

View File

@@ -0,0 +1,14 @@
.adf-media-player {
display: flex;
video {
display: flex;
flex: 1;
max-height: 90vh;
max-width: 100%;
}
video.adf-video-player.adf-audio-file::-webkit-media-text-track-container {
transform: translateY(-50%);
}
}

View File

@@ -0,0 +1,73 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NgClass, NgForOf } from '@angular/common';
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges, ViewEncapsulation } from '@angular/core';
import { UrlService } from '@alfresco/adf-core';
import { Track } from '../../models/viewer.model';
@Component({
selector: 'adf-media-player',
standalone: true,
templateUrl: './media-player.component.html',
styleUrls: ['./media-player.component.scss'],
host: { class: 'adf-media-player' },
imports: [NgClass, NgForOf],
encapsulation: ViewEncapsulation.None
})
export class MediaPlayerComponent implements OnChanges {
@Input()
urlFile: string;
@Input()
blobFile: Blob;
@Input()
mimeType: string;
@Input()
fileName: string;
/** media subtitles for the media player*/
@Input()
tracks: Track[] = [];
@Output()
error = new EventEmitter<any>();
@Output()
canPlay = new EventEmitter<void>();
constructor(private urlService: UrlService) {}
ngOnChanges(changes: SimpleChanges) {
const blobFile = changes['blobFile'];
if (blobFile?.currentValue) {
this.urlFile = this.urlService.createTrustedUrl(this.blobFile);
return;
}
if (!this.urlFile && !this.blobFile) {
throw new Error('Attribute urlFile or blobFile is required');
}
}
onMediaPlayerError(event: any) {
this.error.emit(event);
}
}

View File

@@ -0,0 +1,47 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { ViewerComponent } from '../viewer.component';
import { ViewerMoreActionsComponent } from '../viewer-more-actions.component';
@Component({
selector: 'adf-viewer-container-more-actions',
standalone: true,
imports: [ViewerComponent, MatIconModule, MatMenuModule, ViewerMoreActionsComponent],
template: `
<adf-viewer>
<adf-viewer-more-actions>
<button mat-menu-item>
<mat-icon>dialpad</mat-icon>
<span>Action One</span>
</button>
<button mat-menu-item [disabled]="true">
<mat-icon>voicemail</mat-icon>
<span>Action Two</span>
</button>
<button mat-menu-item>
<mat-icon>notifications_off</mat-icon>
<span>Action Three</span>
</button>
</adf-viewer-more-actions>
</adf-viewer>
`
})
export class ViewerWithCustomMoreActionsComponent {}

View File

@@ -0,0 +1,47 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { ViewerComponent } from '../viewer.component';
import { ViewerOpenWithComponent } from '../viewer-open-with.component';
@Component({
selector: 'adf-viewer-container-open-with',
standalone: true,
imports: [ViewerComponent, MatIconModule, MatMenuModule, ViewerOpenWithComponent],
template: `
<adf-viewer>
<adf-viewer-open-with>
<button mat-menu-item>
<mat-icon>dialpad</mat-icon>
<span>Option 1</span>
</button>
<button mat-menu-item [disabled]="true">
<mat-icon>voicemail</mat-icon>
<span>Option 2</span>
</button>
<button mat-menu-item>
<mat-icon>notifications_off</mat-icon>
<span>Option 3</span>
</button>
</adf-viewer-open-with>
</adf-viewer>
`
})
export class ViewerWithCustomOpenWithComponent {}

View File

@@ -0,0 +1,34 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component } from '@angular/core';
import { ViewerComponent } from '../viewer.component';
import { ViewerSidebarComponent } from '../viewer-sidebar.component';
@Component({
selector: 'adf-viewer-container-sidebar',
standalone: true,
imports: [ViewerComponent, ViewerSidebarComponent],
template: `
<adf-viewer>
<adf-viewer-sidebar>
<div class="custom-sidebar"></div>
</adf-viewer-sidebar>
</adf-viewer>
`
})
export class ViewerWithCustomSidebarComponent {}

View File

@@ -0,0 +1,38 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { ViewerComponent } from '../viewer.component';
import { ViewerToolbarActionsComponent } from '../viewer-toolbar-actions.component';
@Component({
selector: 'adf-viewer-container-toolbar-actions',
standalone: true,
imports: [ViewerComponent, MatIconModule, MatButtonModule, ViewerToolbarActionsComponent],
template: `
<adf-viewer>
<adf-viewer-toolbar-actions>
<button mat-icon-button id="custom-button">
<mat-icon>alarm</mat-icon>
</button>
</adf-viewer-toolbar-actions>
</adf-viewer>
`
})
export class ViewerWithCustomToolbarActionsComponent {}

View File

@@ -0,0 +1,34 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component } from '@angular/core';
import { ViewerComponent } from '../viewer.component';
import { ViewerToolbarComponent } from '../viewer-toolbar.component';
@Component({
selector: 'adf-viewer-container-toolbar',
standalone: true,
imports: [ViewerComponent, ViewerToolbarComponent],
template: `
<adf-viewer>
<adf-viewer-toolbar>
<div class="custom-toolbar-element"></div>
</adf-viewer-toolbar>
</adf-viewer>
`
})
export class ViewerWithCustomToolbarComponent {}

View File

@@ -0,0 +1,37 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export default {
GlobalWorkerOptions: {},
getDocument() {
return {
loadingTask: () => ({
destroy: () => Promise.resolve()
}),
promise: new Promise((resolve) => {
resolve({
numPages: 6,
getPage: () => 'fakePage'
});
})
};
},
PasswordResponses: {
NEED_PASSWORD: 1,
INCORRECT_PASSWORD: 2
}
};

View File

@@ -0,0 +1,31 @@
<div mat-dialog-title>
<mat-icon>lock</mat-icon>
</div>
<mat-dialog-content>
<form (submit)="submit()">
<mat-form-field class="adf-full-width">
<input matInput
data-automation-id='adf-password-dialog-input'
type="password"
placeholder="{{ 'ADF_VIEWER.PDF_DIALOG.PLACEHOLDER' | translate }}"
[formControl]="passwordFormControl" />
</mat-form-field>
<mat-error *ngIf="isError()" data-automation-id='adf-password-dialog-error'>{{ 'ADF_VIEWER.PDF_DIALOG.ERROR' | translate }}</mat-error>
</form>
</mat-dialog-content>
<mat-dialog-actions class="adf-dialog-buttons">
<span class="adf-fill-remaining-space"></span>
<button mat-button mat-dialog-close data-automation-id='adf-password-dialog-close'>{{ 'ADF_VIEWER.PDF_DIALOG.CLOSE' | translate }}</button>
<button mat-button
data-automation-id='adf-password-dialog-submit'
class="adf-dialog-action-button"
[disabled]="!isValid()"
(click)="submit()">
{{ 'ADF_VIEWER.PDF_DIALOG.SUBMIT' | translate }}
</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,15 @@
.adf-fill-remaining-space {
flex: 1 1 auto;
}
.adf-full-width {
width: 100%;
}
.adf-dialog-buttons button {
text-transform: uppercase;
}
.adf-dialog-action-button:enabled {
color: var(--theme-primary-color);
}

View File

@@ -0,0 +1,103 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CoreTestingModule } from '../../../testing';
import { PdfPasswordDialogComponent } from './pdf-viewer-password-dialog';
declare const pdfjsLib: any;
describe('PdfPasswordDialogComponent', () => {
let component: PdfPasswordDialogComponent;
let fixture: ComponentFixture<PdfPasswordDialogComponent>;
let dialogRef: MatDialogRef<PdfPasswordDialogComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CoreTestingModule],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: {
reason: null
}
},
{
provide: MatDialogRef,
useValue: {
close: jasmine.createSpy('open')
}
}
]
});
fixture = TestBed.createComponent(PdfPasswordDialogComponent);
component = fixture.componentInstance;
dialogRef = TestBed.inject(MatDialogRef);
});
it('should have empty default value', () => {
fixture.detectChanges();
expect(component.passwordFormControl.value).toBe('');
});
describe('isError', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should return false', () => {
component.data.reason = pdfjsLib.PasswordResponses.NEED_PASSWORD;
expect(component.isError()).toBe(false);
});
it('should return true', () => {
component.data.reason = pdfjsLib.PasswordResponses.INCORRECT_PASSWORD;
expect(component.isError()).toBe(true);
});
});
describe('isValid', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should return false when input has no value', () => {
component.passwordFormControl.setValue('');
expect(component.isValid()).toBe(false);
});
it('should return true when input has a valid value', () => {
component.passwordFormControl.setValue('some-text');
expect(component.isValid()).toBe(true);
});
});
it('should close dialog with input value', () => {
fixture.detectChanges();
component.passwordFormControl.setValue('some-value');
component.submit();
expect(dialogRef.close).toHaveBeenCalledWith('some-value');
});
});

View File

@@ -0,0 +1,58 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NgIf } from '@angular/common';
import { Component, Inject, OnInit, ViewEncapsulation } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { ReactiveFormsModule, UntypedFormControl, Validators } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslateModule } from '@ngx-translate/core';
declare const pdfjsLib: any;
@Component({
selector: 'adf-pdf-viewer-password-dialog',
standalone: true,
templateUrl: './pdf-viewer-password-dialog.html',
styleUrls: ['./pdf-viewer-password-dialog.scss'],
imports: [MatDialogModule, MatIconModule, MatFormFieldModule, MatInputModule, ReactiveFormsModule, TranslateModule, NgIf, MatButtonModule],
encapsulation: ViewEncapsulation.None
})
export class PdfPasswordDialogComponent implements OnInit {
passwordFormControl: UntypedFormControl;
constructor(private dialogRef: MatDialogRef<PdfPasswordDialogComponent>, @Inject(MAT_DIALOG_DATA) public data: any) {}
ngOnInit() {
this.passwordFormControl = new UntypedFormControl('', [Validators.required]);
}
isError(): boolean {
return this.data.reason === pdfjsLib.PasswordResponses.INCORRECT_PASSWORD;
}
isValid(): boolean {
return !this.passwordFormControl.hasError('required');
}
submit(): void {
this.dialogRef.close(this.passwordFormControl.value);
}
}

View File

@@ -0,0 +1,6 @@
<ng-container *ngIf="image$ | async as image">
<img [src]="image" role="button"
[alt]="'ADF_VIEWER.SIDEBAR.THUMBNAILS.PAGE' | translate: { pageNum: page.id }"
title="{{ 'ADF_VIEWER.SIDEBAR.THUMBNAILS.PAGE' | translate: { pageNum: page.id } }}"
[attr.aria-label]="'ADF_VIEWER.SIDEBAR.THUMBNAILS.PAGE' | translate: { pageNum: page.id }">
</ng-container>

View File

@@ -0,0 +1,74 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DomSanitizer } from '@angular/platform-browser';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PdfThumbComponent } from './pdf-viewer-thumb.component';
import { CoreTestingModule } from '../../../testing';
describe('PdfThumbComponent', () => {
let fixture: ComponentFixture<PdfThumbComponent>;
let component: PdfThumbComponent;
const domSanitizer = {
bypassSecurityTrustUrl: () => 'image-data'
};
const width = 91;
const height = 119;
const page = {
id: 'pageId',
getPage: jasmine.createSpy('getPage').and.returnValue(
Promise.resolve({
getViewport: () => ({ width, height }),
render: jasmine.createSpy('render').and.returnValue({ promise: Promise.resolve() })
})
),
getWidth: jasmine.createSpy('getWidth').and.returnValue(width),
getHeight: jasmine.createSpy('getHeight').and.returnValue(height)
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CoreTestingModule],
providers: [
{
provide: DomSanitizer,
useValue: domSanitizer
}
]
});
fixture = TestBed.createComponent(PdfThumbComponent);
component = fixture.componentInstance;
});
it('should have resolve image data', (done) => {
component.page = page;
fixture.detectChanges();
component.image$.then((result) => {
expect(result).toBe('image-data');
done();
});
});
it('should focus element', () => {
component.page = page;
fixture.detectChanges();
component.focus();
expect(fixture.debugElement.nativeElement.id).toBe(document.activeElement.id);
});
});

View File

@@ -0,0 +1,71 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FocusableOption } from '@angular/cdk/a11y';
import { AsyncPipe, NgIf } from '@angular/common';
import { Component, ElementRef, Input, OnInit, ViewEncapsulation } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'adf-pdf-thumb',
standalone: true,
templateUrl: './pdf-viewer-thumb.component.html',
encapsulation: ViewEncapsulation.None,
imports: [AsyncPipe, TranslateModule, NgIf],
host: { tabindex: '0' }
})
export class PdfThumbComponent implements OnInit, FocusableOption {
@Input()
page: any = null;
image$: Promise<string>;
constructor(private sanitizer: DomSanitizer, private element: ElementRef) {}
ngOnInit() {
this.image$ = this.page.getPage().then((page) => this.getThumb(page));
}
focus() {
this.element.nativeElement.focus();
}
private getThumb(page): Promise<string> {
const viewport = page.getViewport({ scale: 1 });
const canvas = this.getCanvas();
const scale = Math.min(canvas.height / viewport.height, canvas.width / viewport.width);
return page
.render({
canvasContext: canvas.getContext('2d'),
viewport: page.getViewport({ scale })
})
.promise.then(() => {
const imageSource = canvas.toDataURL();
return this.sanitizer.bypassSecurityTrustUrl(imageSource);
});
}
private getCanvas(): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = this.page.getWidth();
canvas.height = this.page.getHeight();
return canvas;
}
}

View File

@@ -0,0 +1,11 @@
<div class="adf-pdf-thumbnails__content"
data-automation-id='adf-thumbnails-content'
[style.height.px]="virtualHeight"
[style.transform]="'translate(-50%, ' + translateY + 'px)'">
<adf-pdf-thumb *ngFor="let page of renderItems; trackBy: trackByFn"
class="adf-pdf-thumbnails__thumb"
[id]="page.id"
[ngClass]="{'adf-pdf-thumbnails__thumb--selected' : isSelected(page.id)}"
[page]="page"
(click)="goTo(page.id)" />
</div>

View File

@@ -0,0 +1,30 @@
.adf-pdf-thumbnails {
display: block;
overflow: hidden;
overflow-y: auto;
height: 100%;
position: relative;
&__content {
top: 5px;
left: 50%;
height: 0;
position: absolute;
}
&__thumb {
cursor: pointer;
display: block;
width: 91px;
background: var(--theme-background-color);
margin-bottom: 15px;
}
&__thumb:hover {
box-shadow: 0 0 5px 0 var(--adf-theme-foreground-text-color-087);
}
&__thumb--selected {
border: 2px solid var(--theme-accent-color-a200);
}
}

View File

@@ -0,0 +1,236 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PdfThumbListComponent } from './pdf-viewer-thumbnails.component';
import { CoreTestingModule, UnitTestingUtils } from '../../../testing';
import { DOWN_ARROW, ESCAPE, UP_ARROW } from '@angular/cdk/keycodes';
declare const pdfjsViewer: any;
describe('PdfThumbListComponent', () => {
let fixture: ComponentFixture<PdfThumbListComponent>;
let component: PdfThumbListComponent;
let testingUtils: UnitTestingUtils;
const page = (id) => ({
id,
getPage: Promise.resolve()
});
const viewerMock = {
_currentPageNumber: null,
set currentPageNumber(pageNum) {
this._currentPageNumber = pageNum;
/* cspell:disable-next-line */
this.eventBus.dispatch('pagechanging', { pageNumber: pageNum });
},
get currentPageNumber() {
return this._currentPageNumber;
},
pdfDocument: {
getPage: () =>
Promise.resolve({
getViewport: () => ({ height: 421, width: 335 }),
render: jasmine.createSpy('render').and.returnValue({ promise: Promise.resolve() })
})
},
_pages: [
page(1),
page(2),
page(3),
page(4),
page(5),
page(6),
page(7),
page(8),
page(9),
page(10),
page(11),
page(12),
page(13),
page(14),
page(15),
page(16)
],
eventBus: new pdfjsViewer.EventBus()
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CoreTestingModule]
});
fixture = TestBed.createComponent(PdfThumbListComponent);
testingUtils = new UnitTestingUtils(fixture.debugElement);
component = fixture.componentInstance;
component.pdfViewer = viewerMock;
// provide scrollable container
fixture.nativeElement.style.display = 'block';
fixture.nativeElement.style.height = '700px';
fixture.nativeElement.style.overflow = 'scroll';
const content = testingUtils.getByCSS('.adf-pdf-thumbnails__content').nativeElement;
content.style.height = '2000px';
content.style.position = 'unset';
});
it('should render initial rage of items', () => {
fixture.nativeElement.scrollTop = 0;
fixture.detectChanges();
const renderedIds = component.renderItems.map((item) => item.id);
// eslint-disable-next-line no-underscore-dangle
const rangeIds = viewerMock._pages.slice(0, 6).map((item) => item.id);
expect(renderedIds).toEqual(rangeIds);
});
it('should render next range on scroll', () => {
component.currentHeight = 114;
fixture.nativeElement.scrollTop = 700;
fixture.detectChanges();
const renderedIds = component.renderItems.map((item) => item.id);
// eslint-disable-next-line no-underscore-dangle
const rangeIds = viewerMock._pages.slice(5, 12).map((item) => item.id);
expect(renderedIds).toEqual(rangeIds);
});
it('should render items containing current document page', () => {
fixture.detectChanges();
const renderedIds = component.renderItems.map((item) => item.id);
expect(renderedIds).not.toContain(10);
component.scrollInto(10);
const newRenderedIds = component.renderItems.map((item) => item.id);
expect(newRenderedIds).toContain(10);
});
it('should not change items if range contains current document page', () => {
fixture.nativeElement.scrollTop = 1700;
fixture.detectChanges();
const renderedIds = component.renderItems.map((item) => item.id);
expect(renderedIds).toContain(12);
/* cspell:disable-next-line */
viewerMock.eventBus.dispatch('pagechanging', { pageNumber: 12 });
const newRenderedIds = component.renderItems.map((item) => item.id);
expect(newRenderedIds).toContain(12);
});
it('should scroll thumbnail height amount to buffer thumbnail onPageChange event', () => {
spyOn(component, 'scrollInto');
fixture.detectChanges();
expect(component.renderItems[component.renderItems.length - 1].id).toBe(6);
expect(fixture.debugElement.nativeElement.scrollTop).toBe(0);
component.pdfViewer.eventBus.dispatch('pagechanging', { pageNumber: 6 });
expect(component.scrollInto).not.toHaveBeenCalled();
expect(fixture.debugElement.nativeElement.scrollTop).toBe(0);
});
it('should set active current page on onPageChange event', () => {
fixture.detectChanges();
component.pdfViewer.eventBus.dispatch('pagechanging', { pageNumber: 6 });
expect(document.activeElement.id).toBe('6');
});
it('should return current viewed page as selected', () => {
fixture.nativeElement.scrollTop = 0;
fixture.detectChanges();
viewerMock.currentPageNumber = 2;
expect(component.isSelected(2)).toBe(true);
});
it('should go to selected page', () => {
fixture.detectChanges();
component.goTo(12);
expect(viewerMock.currentPageNumber).toBe(12);
});
describe('Keyboard events', () => {
it('should select next page in the list on DOWN_ARROW event', () => {
const event = new KeyboardEvent('keydown', { keyCode: DOWN_ARROW } as KeyboardEventInit);
fixture.detectChanges();
component.goTo(1);
expect(document.activeElement.id).toBe('1');
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(document.activeElement.id).toBe('2');
});
it('should select previous page in the list on UP_ARROW event', () => {
const event = new KeyboardEvent('keydown', { keyCode: UP_ARROW } as KeyboardEventInit);
fixture.detectChanges();
component.goTo(2);
expect(document.activeElement.id).toBe('2');
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(document.activeElement.id).toBe('1');
});
it('should not select previous page if it is the first page', () => {
const event = new KeyboardEvent('keydown', { keyCode: UP_ARROW } as KeyboardEventInit);
fixture.detectChanges();
component.goTo(1);
expect(document.activeElement.id).toBe('1');
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(document.activeElement.id).toBe('1');
});
it('should not select next item if it is the last page', () => {
const event = new KeyboardEvent('keydown', { keyCode: DOWN_ARROW } as KeyboardEventInit);
fixture.detectChanges();
component.scrollInto(16);
fixture.detectChanges();
component.pdfViewer.eventBus.dispatch('pagechanging', { pageNumber: 16 });
expect(document.activeElement.id).toBe('16');
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(document.activeElement.id).toBe('16');
});
it('should emit on ESCAPE event', () => {
const event = new KeyboardEvent('keydown', { keyCode: ESCAPE } as KeyboardEventInit);
spyOn(component.close, 'emit');
fixture.detectChanges();
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(component.close.emit).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,249 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FocusKeyManager } from '@angular/cdk/a11y';
import { DOWN_ARROW, ESCAPE, TAB, UP_ARROW } from '@angular/cdk/keycodes';
import { DOCUMENT, NgClass, NgForOf } from '@angular/common';
import {
AfterViewInit,
Component,
ContentChild,
ElementRef,
EventEmitter,
HostListener,
Inject,
Input,
OnDestroy,
OnInit,
Output,
QueryList,
TemplateRef,
ViewChildren,
ViewEncapsulation
} from '@angular/core';
import { delay } from 'rxjs/operators';
import { PdfThumbComponent } from '../pdf-viewer-thumb/pdf-viewer-thumb.component';
@Component({
selector: 'adf-pdf-thumbnails',
standalone: true,
templateUrl: './pdf-viewer-thumbnails.component.html',
styleUrls: ['./pdf-viewer-thumbnails.component.scss'],
host: { class: 'adf-pdf-thumbnails' },
imports: [PdfThumbComponent, NgClass, NgForOf],
encapsulation: ViewEncapsulation.None
})
export class PdfThumbListComponent implements OnInit, AfterViewInit, OnDestroy {
@Input({ required: true }) pdfViewer: any;
@Output()
close = new EventEmitter<void>();
virtualHeight: number = 0;
translateY: number = 0;
renderItems = [];
width: number = 91;
currentHeight: number = 0;
private items = [];
private margin: number = 15;
private itemHeight: number = 114 + this.margin;
private previouslyFocusedElement: HTMLElement | null = null;
private keyManager: FocusKeyManager<PdfThumbComponent>;
@ContentChild(TemplateRef)
template: any;
@ViewChildren(PdfThumbComponent)
thumbsList: QueryList<PdfThumbComponent>;
@HostListener('keydown', ['$event'])
onKeydown(event: KeyboardEvent): void {
const keyCode = event.keyCode;
if (keyCode === UP_ARROW && this.canSelectPreviousItem()) {
this.pdfViewer.currentPageNumber -= 1;
}
if (keyCode === DOWN_ARROW && this.canSelectNextItem()) {
this.pdfViewer.currentPageNumber += 1;
}
if (keyCode === TAB) {
if (this.canSelectNextItem()) {
this.pdfViewer.currentPageNumber += 1;
} else {
this.close.emit();
}
}
if (keyCode === ESCAPE) {
this.close.emit();
}
this.keyManager.setFocusOrigin('keyboard');
event.preventDefault();
}
@HostListener('window:resize')
onResize() {
this.calculateItems();
}
constructor(private element: ElementRef, @Inject(DOCUMENT) private document: any) {
this.calculateItems = this.calculateItems.bind(this);
this.onPageChange = this.onPageChange.bind(this);
}
ngOnInit() {
/* cspell:disable-next-line */
this.pdfViewer.eventBus.on('pagechanging', this.onPageChange);
this.element.nativeElement.addEventListener('scroll', this.calculateItems, true);
this.setHeight(this.pdfViewer.currentPageNumber);
this.items = this.getPages();
this.calculateItems();
this.previouslyFocusedElement = this.document.activeElement as HTMLElement;
}
ngAfterViewInit() {
this.keyManager = new FocusKeyManager(this.thumbsList);
this.thumbsList.changes.pipe(delay(0)).subscribe(() => this.keyManager.setActiveItem(this.getPageIndex(this.pdfViewer.currentPageNumber)));
setTimeout(() => {
this.scrollInto(this.pdfViewer.currentPageNumber);
this.keyManager.setActiveItem(this.getPageIndex(this.pdfViewer.currentPageNumber));
}, 0);
}
ngOnDestroy() {
this.element.nativeElement.removeEventListener('scroll', this.calculateItems, true);
/* cspell:disable-next-line */
this.pdfViewer.eventBus.on('pagechanging', this.onPageChange);
if (this.previouslyFocusedElement) {
this.previouslyFocusedElement.focus();
this.previouslyFocusedElement = null;
}
}
trackByFn(_: number, item: any): number {
return item.id;
}
isSelected(pageNumber: number) {
return this.pdfViewer.currentPageNumber === pageNumber;
}
goTo(pageNumber: number) {
this.pdfViewer.currentPageNumber = pageNumber;
}
scrollInto(pageNumber: number) {
if (this.items.length) {
const index: number = this.items.findIndex((element) => element.id === pageNumber);
if (index < 0 || index >= this.items.length) {
return;
}
this.element.nativeElement.scrollTop = index * this.itemHeight;
this.calculateItems();
}
}
getPages() {
// eslint-disable-next-line no-underscore-dangle
return this.pdfViewer._pages.map((page) => ({
id: page.id,
getWidth: () => this.width,
getHeight: () => this.currentHeight,
getPage: () => this.pdfViewer.pdfDocument.getPage(page.id)
}));
}
private setHeight(id): number {
const height = this.pdfViewer.pdfDocument.getPage(id).then((page) => this.calculateHeight(page));
return height;
}
private calculateHeight(page) {
const viewport = page.getViewport({ scale: 1 });
const pageRatio = viewport.width / viewport.height;
const height = Math.floor(this.width / pageRatio);
this.currentHeight = height;
this.itemHeight = height + this.margin;
}
private calculateItems() {
const { element, viewPort, itemsInView } = this.getContainerSetup();
const indexByScrollTop = ((element.scrollTop / viewPort) * this.items.length) / itemsInView;
const start = Math.floor(indexByScrollTop);
const end = Math.ceil(indexByScrollTop) + itemsInView;
this.translateY = this.itemHeight * Math.ceil(start);
this.virtualHeight = this.itemHeight * this.items.length - this.translateY;
this.renderItems = this.items.slice(start, end);
}
private getContainerSetup() {
const element = this.element.nativeElement;
const elementRec = element.getBoundingClientRect();
const itemsInView = Math.ceil(elementRec.height / this.itemHeight);
const viewPort = (this.itemHeight * this.items.length) / itemsInView;
return {
element,
viewPort,
itemsInView
};
}
private onPageChange(event) {
const index = this.renderItems.findIndex((element) => element.id === event.pageNumber);
if (index < 0) {
this.scrollInto(event.pageNumber);
}
if (index >= this.renderItems.length - 1) {
this.element.nativeElement.scrollTop += this.itemHeight;
}
this.keyManager.setActiveItem(this.getPageIndex(event.pageNumber));
}
private getPageIndex(pageNumber: number): number {
const thumbsListArray = this.thumbsList.toArray();
return thumbsListArray.findIndex((el) => el.page.id === pageNumber);
}
private canSelectNextItem(): boolean {
return this.pdfViewer.currentPageNumber !== this.pdfViewer.pagesCount;
}
private canSelectPreviousItem(): boolean {
return this.pdfViewer.currentPageNumber !== 1;
}
}

View File

@@ -0,0 +1,208 @@
.adf-pdf-viewer {
.textLayer {
position: absolute;
inset: 0;
overflow: hidden;
opacity: 0.2;
line-height: 1;
border: 1px solid gray;
& > div {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0 0;
}
.adf-highlight {
margin: -1px;
padding: 1px;
background-color: rgb(180, 0, 170);
border-radius: 4px;
&.adf-begin {
border-radius: 4px 0 0 4px;
}
&.adf-end {
border-radius: 0 4px 4px 0;
}
&.adf-middle {
border-radius: 0;
}
&.adf-selected {
background-color: rgb(0, 100, 0);
}
}
&::selection {
background: rgb(0, 0, 255);
}
.adf-endOfContent {
display: block;
position: absolute;
inset: 0;
top: 100%;
z-index: -1;
cursor: default;
user-select: none;
&.adf-active {
top: 0;
}
}
}
.adf-annotationLayer {
section {
position: absolute;
}
.adf-linkAnnotation {
& > a {
position: absolute;
font-size: 1em;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* stylelint-disable */
background: url('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7') 0 0 repeat;
/* stylelint-enable */
&:hover {
opacity: 0.2;
background: #ff0;
box-shadow: 0 2px 10px #ff0;
}
}
}
.adf-textAnnotation {
img {
position: absolute;
cursor: pointer;
}
}
.adf-popupWrapper {
position: absolute;
width: 20em;
}
.adf-popup {
position: absolute;
z-index: 200;
max-width: 20em;
background-color: #ff9;
box-shadow: 0 2px 5px #333;
border-radius: 2px;
padding: 0.6em;
margin-left: 5px;
cursor: pointer;
word-wrap: break-word;
h1 {
font-size: 1em;
border-bottom: 1px solid #000;
padding-bottom: 0.2em;
}
p {
padding-top: 0.2em;
}
}
.adf-highlightAnnotation,
.adf-underlineAnnotation,
.adf-squigglyAnnotation,
.adf-strikeoutAnnotation,
.adf-fileAttachmentAnnotation {
cursor: pointer;
}
}
.adf-pdfViewer {
.canvasWrapper {
overflow: hidden;
}
.page {
direction: ltr;
width: 816px;
height: 1056px;
margin: 1px auto -8px;
position: relative;
overflow: visible;
border: 9px solid transparent;
background-clip: content-box;
background-color: white;
canvas {
margin: 0;
display: block;
}
.adf-loadingIcon {
position: absolute;
display: block;
inset: 0;
width: 100px;
height: 100px;
left: 50%;
top: 50%;
margin-top: -50px;
margin-left: -50px;
font-size: 5px;
text-indent: -9999em;
border-top: 1.1em solid rgba(3, 0, 2, 0.2);
border-right: 1.1em solid rgba(3, 0, 2, 0.2);
border-bottom: 1.1em solid rgba(3, 0, 2, 0.2);
border-left: 1.1em solid #030002;
animation: load8 1.1s infinite linear;
border-radius: 50%;
&::after {
border-radius: 50%;
}
}
* {
padding: 0;
margin: 0;
}
}
&.adf-removePageBorders {
.adf-page {
margin: 0 auto 10px;
border: none;
}
}
}
.adf-hidden,
[hidden] {
display: none;
}
}
.adf-viewer-pdf-viewer {
overflow: auto;
-webkit-overflow-scrolling: touch;
position: absolute;
inset: 0;
outline: none;
}
html[dir='ltr'] .adf-viewer-pdf-viewer {
box-shadow: inset 1px 0 0 hsla(0deg, 0%, 100%, 0.05);
}
html[dir='rtl'] .adf-viewer-pdf-viewer {
box-shadow: inset -1px 0 0 hsla(0deg, 0%, 100%, 0.05);
}

View File

@@ -0,0 +1,118 @@
<div class="adf-pdf-viewer__container">
<ng-container *ngIf="showThumbnails">
<div class="adf-pdf-viewer__thumbnails">
<div class="adf-thumbnails-template__container">
<div class="adf-thumbnails-template__buttons">
<button mat-icon-button
data-automation-id='adf-thumbnails-close'
(click)="toggleThumbnails()"
[attr.aria-label]="'ADF_VIEWER.ARIA.THUMBNAILS_PANLEL_CLOSE' | translate"
title="{{ 'ADF_VIEWER.ACTIONS.CLOSE' | translate }}">
<mat-icon>close</mat-icon>
</button>
</div>
<ng-container *ngIf="thumbnailsTemplate">
<ng-container *ngTemplateOutlet="thumbnailsTemplate;context:pdfThumbnailsContext" />
</ng-container>
<adf-pdf-thumbnails *ngIf="!thumbnailsTemplate && !isPanelDisabled"
(close)="toggleThumbnails()"
[pdfViewer]="pdfViewer" />
</div>
</div>
</ng-container>
<div class="adf-pdf-viewer__content">
<div [id]="randomPdfId + '-viewer-pdf-viewer'"
class="adf-viewer-pdf-viewer"
(window:resize)="onResize()">
<div [id]="randomPdfId + '-viewer-viewerPdf'"
class="adf-pdfViewer pdfViewer"
role="document"
tabindex="0"
aria-expanded="true">
<div id="loader-container" class="adf-loader-container">
<div class="adf-loader-item">
<mat-progress-bar class="adf-loader-item-progress-bar" mode="indeterminate" />
</div>
</div>
</div>
</div>
</div>
</div>
<div class="adf-pdf-viewer__toolbar" *ngIf="showToolbar" [ngStyle]="documentOverflow && {bottom: '25px'}">
<adf-toolbar>
<ng-container *ngIf="allowThumbnails">
<button mat-icon-button
[attr.aria-label]="'ADF_VIEWER.ARIA.THUMBNAILS' | translate"
[attr.aria-expanded]="showThumbnails"
data-automation-id="adf-thumbnails-button"
[disabled]="isPanelDisabled"
(click)="toggleThumbnails()">
<mat-icon>dashboard</mat-icon>
</button>
<adf-toolbar-divider />
</ng-container>
<button id="viewer-previous-page-button"
title="{{ 'ADF_VIEWER.ARIA.PREVIOUS_PAGE' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.PREVIOUS_PAGE' | translate }}"
mat-icon-button
(click)="previousPage()">
<mat-icon>keyboard_arrow_up</mat-icon>
</button>
<button id="viewer-next-page-button"
title="{{ 'ADF_VIEWER.ARIA.NEXT_PAGE' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.NEXT_PAGE' | translate }}"
mat-icon-button
(click)="nextPage()">
<mat-icon>keyboard_arrow_down</mat-icon>
</button>
<div class="adf-pdf-viewer__toolbar-page-selector">
<label for="page-selector">{{ 'ADF_VIEWER.PAGE_LABEL.SHOWING' | translate }}</label>
<input #page
id="page-selector"
type="text"
data-automation-id="adf-page-selector"
pattern="-?[0-9]*(\.[0-9]+)?"
value="{{ displayPage }}"
[attr.aria-label]="'ADF_VIEWER.PAGE_LABEL.PAGE_SELECTOR_LABEL' | translate"
(keyup.enter)="inputPage(page.value)">
<span>{{ 'ADF_VIEWER.PAGE_LABEL.OF' | translate }} {{ totalPages }}</span>
</div>
<div class="adf-pdf-viewer__toolbar-page-scale" data-automation-id="adf-page-scale">
{{ currentScaleText }}
</div>
<button id="viewer-zoom-in-button"
title="{{ 'ADF_VIEWER.ARIA.ZOOM_IN' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.ZOOM_IN' | translate }}"
mat-icon-button
(click)="zoomIn()">
<mat-icon>zoom_in</mat-icon>
</button>
<button id="viewer-zoom-out-button"
title="{{ 'ADF_VIEWER.ARIA.ZOOM_OUT' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.ZOOM_OUT' | translate }}"
mat-icon-button
(click)="zoomOut()">
<mat-icon>zoom_out</mat-icon>
</button>
<button id="viewer-scale-page-button"
role="button" aria-pressed="true"
title="{{ 'ADF_VIEWER.ARIA.FIT_PAGE' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.FIT_PAGE' | translate }}"
mat-icon-button
(click)="pageFit()">
<mat-icon>zoom_out_map</mat-icon>
</button>
</adf-toolbar>
</div>

View File

@@ -0,0 +1,143 @@
@use 'mat-selectors' as ms;
.adf-pdf-viewer {
width: 100%;
height: 100%;
margin: 0;
.adf-loader-container {
display: flex;
flex-direction: row;
height: 100%;
}
&__thumbnails {
position: relative;
height: 100%;
width: 190px;
background-color: rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
padding: 0;
.adf-info-drawer-layout {
display: flex;
flex-direction: column;
flex: 1;
background: #e6e6e6;
}
.adf-info-drawer-layout-header {
margin-bottom: 0;
}
.adf-info-drawer-layout-content {
padding: 0;
height: 100%;
overflow: hidden;
}
.adf-info-drawer-content {
height: 100%;
}
.adf-info-drawer-layout-content > *:last-child {
height: 100%;
overflow: hidden;
}
}
.adf-thumbnails-template {
&__container {
display: flex;
flex-direction: column;
height: 100%;
}
&__buttons {
height: 45px;
justify-content: flex-end;
align-items: flex-end;
display: flex;
color: var(--adf-theme-foreground-text-color-054);
}
}
&__container {
display: flex;
height: 100%;
min-height: 1px;
}
&__content {
flex: 1 1 auto;
position: relative;
}
.adf-loader-item {
margin: auto;
max-height: 100px;
max-width: 300px;
.adf-loader-item-progress-bar {
max-width: 300px;
margin: 0;
position: absolute;
top: 50%;
left: 50%;
/* stylelint-disable-next-line property-no-vendor-prefix */
-ms-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}
}
&__toolbar {
z-index: 100;
position: absolute;
bottom: 5px;
left: 50%;
transform: translateX(-50%);
.adf-toolbar #{ms.$mat-toolbar} {
max-height: 48px;
background-color: var(--adf-theme-background-card-color);
border-width: 0;
border-radius: 2px;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 20.4), 0 0 2px 0 rgba(0, 0, 0, 0.12);
}
&-page-selector {
padding-left: 10px;
padding-right: 10px;
white-space: nowrap;
font-size: var(--theme-body-1-font-size);
& > input {
border: 1px solid var(--adf-theme-foreground-text-color-007);
background-color: var(--adf-theme-background-card-color);
color: inherit;
font-size: var(--theme-body-1-font-size);
padding: 5px;
height: 24px;
line-height: 24px;
text-align: right;
width: 33px;
margin-right: 4px;
outline-width: 1px;
outline-color: gray;
}
}
&-page-scale {
cursor: default;
width: 79px;
height: 24px;
font-size: var(--theme-body-1-font-size);
border: 1px solid var(--adf-theme-foreground-text-color-007);
text-align: center;
line-height: 24px;
margin-left: 4px;
margin-right: 4px;
}
}
}

View File

@@ -0,0 +1,603 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LEFT_ARROW, RIGHT_ARROW } from '@angular/cdk/keycodes';
import { Component, SimpleChange, ViewChild } from '@angular/core';
import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { By } from '@angular/platform-browser';
import { of } from 'rxjs';
import { AppConfigService } from '../../../app-config';
import { EventMock } from '../../../mock';
import { NoopAuthModule, NoopTranslateModule, UnitTestingUtils } from '../../../testing';
import { RenderingQueueServices } from '../../services/rendering-queue.services';
import { PdfThumbListComponent } from '../pdf-viewer-thumbnails/pdf-viewer-thumbnails.component';
import { PDFJS_MODULE, PDFJS_VIEWER_MODULE, PdfViewerComponent } from './pdf-viewer.component';
import pdfjsLibMock from '../mock/pdfjs-lib.mock';
declare const pdfjsLib: any;
@Component({
selector: 'adf-url-test-component',
standalone: true,
imports: [PdfViewerComponent],
template: ` <adf-pdf-viewer [allowThumbnails]="true" [showToolbar]="true" [urlFile]="urlFile" /> `
})
class UrlTestComponent {
@ViewChild(PdfViewerComponent, { static: true })
pdfViewerComponent: PdfViewerComponent;
urlFile: any;
constructor() {
this.urlFile = './fake-test-file.pdf';
}
}
@Component({
selector: 'adf-url-test-password-component',
standalone: true,
imports: [PdfViewerComponent],
template: ` <adf-pdf-viewer [allowThumbnails]="true" [showToolbar]="true" [urlFile]="urlFile" /> `
})
class UrlTestPasswordComponent {
@ViewChild(PdfViewerComponent, { static: true })
pdfViewerComponent: PdfViewerComponent;
urlFile: any;
constructor() {
this.urlFile = './fake-test-password-file.pdf';
}
}
@Component({
standalone: true,
imports: [PdfViewerComponent],
template: ` <adf-pdf-viewer [allowThumbnails]="true" [showToolbar]="true" [blobFile]="blobFile" /> `
})
class BlobTestComponent {
@ViewChild(PdfViewerComponent, { static: true })
pdfViewerComponent: PdfViewerComponent;
blobFile: any;
constructor() {
this.blobFile = this.createFakeBlob();
}
createFakeBlob(): Blob {
const pdfData = atob(
'JVBERi0xLjcKCjEgMCBvYmogICUgZW50cnkgcG9pbnQKPDwKICAvVHlwZSAvQ2F0YWxvZwog' +
'IC9QYWdlcyAyIDAgUgo+PgplbmRvYmoKCjIgMCBvYmoKPDwKICAvVHlwZSAvUGFnZXMKICAv' +
'TWVkaWFCb3ggWyAwIDAgMjAwIDIwMCBdCiAgL0NvdW50IDEKICAvS2lkcyBbIDMgMCBSIF0K' +
'Pj4KZW5kb2JqCgozIDAgb2JqCjw8CiAgL1R5cGUgL1BhZ2UKICAvUGFyZW50IDIgMCBSCiAg' +
'L1Jlc291cmNlcyA8PAogICAgL0ZvbnQgPDwKICAgICAgL0YxIDQgMCBSIAogICAgPj4KICA+' +
'PgogIC9Db250ZW50cyA1IDAgUgo+PgplbmRvYmoKCjQgMCBvYmoKPDwKICAvVHlwZSAvRm9u' +
'dAogIC9TdWJ0eXBlIC9UeXBlMQogIC9CYXNlRm9udCAvVGltZXMtUm9tYW4KPj4KZW5kb2Jq' +
'Cgo1IDAgb2JqICAlIHBhZ2UgY29udGVudAo8PAogIC9MZW5ndGggNDQKPj4Kc3RyZWFtCkJU' +
'CjcwIDUwIFRECi9GMSAxMiBUZgooSGVsbG8sIHdvcmxkISkgVGoKRVQKZW5kc3RyZWFtCmVu' +
'ZG9iagoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDEwIDAwMDAwIG4g' +
'CjAwMDAwMDAwNzkgMDAwMDAgbiAKMDAwMDAwMDE3MyAwMDAwMCBuIAowMDAwMDAwMzAxIDAw' +
'MDAwIG4gCjAwMDAwMDAzODAgMDAwMDAgbiAKdHJhaWxlcgo8PAogIC9TaXplIDYKICAvUm9v' +
'dCAxIDAgUgo+PgpzdGFydHhyZWYKNDkyCiUlRU9G'
);
return new Blob([pdfData], { type: 'application/pdf' });
}
}
describe('Test PdfViewer component', () => {
let component: PdfViewerComponent;
let fixture: ComponentFixture<PdfViewerComponent>;
let change: any;
let dialog: MatDialog;
let testingUtils: UnitTestingUtils;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [NoopAuthModule, NoopTranslateModule, PdfViewerComponent],
providers: [
{
provide: MatDialog,
useValue: {
open: () => {}
}
},
RenderingQueueServices
]
});
fixture = TestBed.createComponent(PdfViewerComponent);
testingUtils = new UnitTestingUtils(fixture.debugElement);
dialog = TestBed.inject(MatDialog);
component = fixture.componentInstance;
component.showToolbar = true;
component.inputPage('1');
fixture.detectChanges();
await fixture.whenStable();
});
afterAll(() => {
fixture.destroy();
});
it('should Loader be present', () => {
expect(testingUtils.getByCSS('.adf-loader-container')).not.toBeNull();
});
describe('Required values', () => {
it('should thrown an error If urlFile is not present', () => {
change = new SimpleChange(null, null, true);
expect(() => {
component.ngOnChanges({ urlFile: change });
}).toThrow(new Error('Attribute urlFile or blobFile is required'));
});
it('should If blobFile is not present thrown an error ', () => {
change = new SimpleChange(null, null, true);
expect(() => {
component.ngOnChanges({ blobFile: change });
}).toThrow(new Error('Attribute urlFile or blobFile is required'));
});
});
describe('View with url file', () => {
let fixtureUrlTestComponent: ComponentFixture<UrlTestComponent>;
let elementUrlTestComponent: HTMLElement;
beforeEach(async () => {
fixtureUrlTestComponent = TestBed.createComponent(UrlTestComponent);
elementUrlTestComponent = fixtureUrlTestComponent.nativeElement;
testingUtils.setDebugElement(fixtureUrlTestComponent.debugElement);
fixtureUrlTestComponent.detectChanges();
await fixtureUrlTestComponent.whenStable();
});
afterEach(() => {
document.body.removeChild(elementUrlTestComponent);
});
it('should Canvas be present', async () => {
fixtureUrlTestComponent.detectChanges();
await fixtureUrlTestComponent.whenStable();
expect(testingUtils.getByCSS('.adf-pdfViewer')).not.toBeNull();
expect(testingUtils.getByCSS('.adf-viewer-pdf-viewer')).not.toBeNull();
});
it('should Input Page elements be present', async () => {
fixtureUrlTestComponent.detectChanges();
await fixtureUrlTestComponent.whenStable();
expect(testingUtils.getByCSS('.viewer-pagenumber-input')).toBeDefined();
expect(testingUtils.getByCSS('.viewer-total-pages')).toBeDefined();
expect(testingUtils.getByCSS('#viewer-previous-page-button')).not.toBeNull();
expect(testingUtils.getByCSS('#viewer-next-page-button')).not.toBeNull();
});
it('should Toolbar be hide if showToolbar is false', async () => {
component.showToolbar = false;
fixtureUrlTestComponent.detectChanges();
await fixtureUrlTestComponent.whenStable();
expect(testingUtils.getByCSS('.viewer-toolbar-command')).toBeNull();
expect(testingUtils.getByCSS('.viewer-toolbar-pagination')).toBeNull();
});
});
describe('View with blob file', () => {
let fixtureBlobTestComponent: ComponentFixture<BlobTestComponent>;
let elementBlobTestComponent: HTMLElement;
beforeEach(async () => {
fixtureBlobTestComponent = TestBed.createComponent(BlobTestComponent);
elementBlobTestComponent = fixtureBlobTestComponent.nativeElement;
testingUtils.setDebugElement(fixtureBlobTestComponent.debugElement);
fixtureBlobTestComponent.detectChanges();
await fixtureBlobTestComponent.whenStable();
});
afterEach(() => {
document.body.removeChild(elementBlobTestComponent);
});
it('should Canvas be present', async () => {
fixtureBlobTestComponent.detectChanges();
await fixtureBlobTestComponent.whenStable();
expect(testingUtils.getByCSS('.adf-pdfViewer')).not.toBeNull();
expect(testingUtils.getByCSS('.adf-viewer-pdf-viewer')).not.toBeNull();
});
it('should Next an Previous Buttons be present', async () => {
fixtureBlobTestComponent.detectChanges();
await fixtureBlobTestComponent.whenStable();
expect(testingUtils.getByCSS('#viewer-previous-page-button')).not.toBeNull();
expect(testingUtils.getByCSS('#viewer-next-page-button')).not.toBeNull();
});
it('should Input Page elements be present', async () => {
fixtureBlobTestComponent.detectChanges();
await fixtureBlobTestComponent.whenStable();
/* cspell:disable-next-line */
expect(testingUtils.getByCSS('.adf-viewer-pagenumber-input')).toBeDefined();
expect(testingUtils.getByCSS('.adf-viewer-total-pages')).toBeDefined();
expect(testingUtils.getByCSS('#viewer-previous-page-button')).not.toBeNull();
expect(testingUtils.getByCSS('#viewer-next-page-button')).not.toBeNull();
});
it('should Toolbar be hide if showToolbar is false', async () => {
fixtureBlobTestComponent.componentInstance.pdfViewerComponent.showToolbar = false;
fixtureBlobTestComponent.detectChanges();
await fixtureBlobTestComponent.whenStable();
expect(testingUtils.getByCSS('.viewer-toolbar-command')).toBeNull();
expect(testingUtils.getByCSS('.viewer-toolbar-pagination')).toBeNull();
});
});
describe('Password protection dialog', () => {
let fixtureUrlTestPasswordComponent: ComponentFixture<UrlTestPasswordComponent>;
let componentUrlTestPasswordComponent: UrlTestPasswordComponent;
describe('Open password dialog', () => {
beforeEach(async () => {
fixtureUrlTestPasswordComponent = TestBed.createComponent(UrlTestPasswordComponent);
componentUrlTestPasswordComponent = fixtureUrlTestPasswordComponent.componentInstance;
spyOn(dialog, 'open').and.callFake((_: any, context: any) => {
if (context.data.reason === pdfjsLib.PasswordResponses.NEED_PASSWORD) {
return {
afterClosed: () => of('wrong_password')
} as any;
}
if (context.data.reason === pdfjsLib.PasswordResponses.INCORRECT_PASSWORD) {
return {
afterClosed: () => of('password')
} as any;
}
return undefined;
});
fixtureUrlTestPasswordComponent.detectChanges();
await fixtureUrlTestPasswordComponent.whenStable();
});
afterEach(() => {
document.body.removeChild(fixtureUrlTestPasswordComponent.nativeElement);
});
it('should try to access protected pdf', async () => {
componentUrlTestPasswordComponent.pdfViewerComponent.onPdfPassword(() => {}, pdfjsLib.PasswordResponses.NEED_PASSWORD);
fixture.detectChanges();
await fixture.whenStable();
expect(dialog.open).toHaveBeenCalledTimes(1);
});
it('should raise dialog asking for password', async () => {
componentUrlTestPasswordComponent.pdfViewerComponent.onPdfPassword(() => {}, pdfjsLib.PasswordResponses.NEED_PASSWORD);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(dialog.open['calls'].all()[0].args[1].data).toEqual({
reason: pdfjsLib.PasswordResponses.NEED_PASSWORD
});
});
it('it should raise dialog with incorrect password', async () => {
componentUrlTestPasswordComponent.pdfViewerComponent.onPdfPassword(() => {}, pdfjsLib.PasswordResponses.INCORRECT_PASSWORD);
fixture.detectChanges();
await fixture.whenStable();
expect(dialog.open['calls'].all()[0].args[1].data).toEqual({
reason: pdfjsLib.PasswordResponses.INCORRECT_PASSWORD
});
});
});
describe('Close password dialog ', () => {
beforeEach(async () => {
fixtureUrlTestPasswordComponent = TestBed.createComponent(UrlTestPasswordComponent);
componentUrlTestPasswordComponent = fixtureUrlTestPasswordComponent.componentInstance;
spyOn(dialog, 'open').and.callFake(
() =>
({
afterClosed: () => of('')
} as any)
);
spyOn(componentUrlTestPasswordComponent.pdfViewerComponent.close, 'emit');
fixtureUrlTestPasswordComponent.detectChanges();
await fixtureUrlTestPasswordComponent.whenStable();
});
afterEach(() => {
document.body.removeChild(fixtureUrlTestPasswordComponent.nativeElement);
});
it('should try to access protected pdf', async () => {
componentUrlTestPasswordComponent.pdfViewerComponent.onPdfPassword(() => {}, pdfjsLib.PasswordResponses.NEED_PASSWORD);
fixture.detectChanges();
await fixture.whenStable();
expect(componentUrlTestPasswordComponent.pdfViewerComponent.close.emit).toHaveBeenCalledWith();
});
});
});
});
describe('Test PdfViewer - Zoom customization', () => {
let fixture: ComponentFixture<PdfViewerComponent>;
let component: PdfViewerComponent;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [NoopAuthModule, NoopTranslateModule, PdfViewerComponent],
providers: [
{
provide: MatDialog,
useValue: {
open: () => {}
}
},
RenderingQueueServices
]
});
fixture = TestBed.createComponent(PdfViewerComponent);
component = fixture.componentInstance;
});
afterAll(() => {
fixture.destroy();
});
it('should use the custom zoom if it is present in the app.config', () => {
const appConfig: AppConfigService = TestBed.inject(AppConfigService);
appConfig.config['adf-viewer.pdf-viewer-scaling'] = 80;
expect(component.getUserScaling()).toBe(0.8);
});
it('should use the minimum scale zoom if the value given in app.config is less than the minimum allowed scale', () => {
const appConfig: AppConfigService = TestBed.inject(AppConfigService);
appConfig.config['adf-viewer.pdf-viewer-scaling'] = 10;
fixture.detectChanges();
expect(component.getUserScaling()).toBe(0.25);
});
it('should use the maximum scale zoom if the value given in app.config is greater than the maximum allowed scale', () => {
const appConfig: AppConfigService = TestBed.inject(AppConfigService);
appConfig.config['adf-viewer.pdf-viewer-scaling'] = 5555;
fixture.detectChanges();
expect(component.getUserScaling()).toBe(10);
});
});
describe('Test PdfViewer - User interaction', () => {
let fixture: ComponentFixture<PdfViewerComponent>;
let component: PdfViewerComponent;
let testingUtils: UnitTestingUtils;
let pdfViewerSpy: jasmine.Spy;
beforeEach(fakeAsync(() => {
pdfViewerSpy = jasmine.createSpy('PDFViewer').and.returnValue({
setDocument: jasmine.createSpy().and.returnValue({
loadingTask: () => ({
destroy: () => Promise.resolve()
}),
promise: new Promise((resolve) => {
resolve({
numPages: 6,
getPage: () => 'fakePage'
});
})
}),
forceRendering: jasmine.createSpy(),
update: jasmine.createSpy(),
currentScaleValue: 1,
_currentPageNumber: 1,
_pages: [{ width: 100, height: 100, scale: 1 }]
});
TestBed.configureTestingModule({
imports: [NoopAuthModule, NoopTranslateModule, PdfViewerComponent],
providers: [
{
provide: MatDialog,
useValue: {
open: () => {}
}
},
RenderingQueueServices,
{ provide: PDFJS_VIEWER_MODULE, useValue: pdfViewerSpy },
{ provide: PDFJS_MODULE, useValue: pdfjsLibMock }
]
});
fixture = TestBed.createComponent(PdfViewerComponent);
component = fixture.componentInstance;
testingUtils = new UnitTestingUtils(fixture.debugElement);
const appConfig: AppConfigService = TestBed.inject(AppConfigService);
appConfig.config['adf-viewer.pdf-viewer-scaling'] = 10;
component['setupPdfJsWorker'] = () => Promise.resolve();
component.urlFile = './fake-test-file.pdf';
fixture.detectChanges();
component.ngOnChanges({ urlFile: { currentValue: './fake-test-file.pdf' } } as any);
flush();
}));
afterAll(() => {
fixture.destroy();
});
it('should init the viewer with annotation mode disabled', () => {
expect(pdfViewerSpy).toHaveBeenCalledWith(jasmine.objectContaining({ annotationMode: 0 }));
});
it('should Total number of pages be loaded', () => {
expect(component.totalPages).toBe(6);
});
it('should nextPage move to the next page', () => {
testingUtils.clickByCSS('#viewer-next-page-button');
expect(component.displayPage).toBe(2);
});
it('should event RIGHT_ARROW keyboard change pages', () => {
fixture.detectChanges();
EventMock.keyDown(RIGHT_ARROW);
expect(component.displayPage).toBe(2);
});
it('should event LEFT_ARROW keyboard change pages', () => {
component.inputPage('2');
EventMock.keyDown(LEFT_ARROW);
expect(component.displayPage).toBe(1);
});
it('should previous page move to the previous page', () => {
testingUtils.clickByCSS('#viewer-next-page-button');
testingUtils.clickByCSS('#viewer-next-page-button');
testingUtils.clickByCSS('#viewer-previous-page-button');
expect(component.displayPage).toBe(2);
});
it('should previous page not move to the previous page if is page 1', () => {
component.previousPage();
expect(component.displayPage).toBe(1);
});
it('should Input page move to the inserted page', () => {
component.inputPage('2');
expect(component.displayPage).toBe(2);
});
describe('Zoom', () => {
it('should zoom in increment the scale value', () => {
const zoomBefore = component.pdfViewer.currentScaleValue;
testingUtils.clickByCSS('#viewer-zoom-in-button');
expect(component.currentScaleMode).toBe('auto');
const currentZoom = component.pdfViewer.currentScaleValue;
expect(zoomBefore < currentZoom).toBe(true);
});
it('should zoom out decrement the scale value', () => {
testingUtils.clickByCSS('#viewer-zoom-in-button');
const zoomBefore = component.pdfViewer.currentScaleValue;
testingUtils.clickByCSS('#viewer-zoom-out-button');
expect(component.currentScaleMode).toBe('auto');
const currentZoom = component.pdfViewer.currentScaleValue;
expect(zoomBefore > currentZoom).toBe(true);
});
it('should it-in button toggle page-fit and auto scale mode', fakeAsync(() => {
tick(250);
expect(component.currentScaleMode).toBe('init');
testingUtils.clickByCSS('#viewer-scale-page-button');
expect(component.currentScaleMode).toBe('page-fit');
testingUtils.clickByCSS('#viewer-scale-page-button');
expect(component.currentScaleMode).toBe('auto');
testingUtils.clickByCSS('#viewer-scale-page-button');
expect(component.currentScaleMode).toBe('page-fit');
}), 300);
});
describe('Resize interaction', () => {
it('should resize event trigger setScaleUpdatePages', () => {
spyOn(component, 'onResize');
EventMock.resizeMobileView();
expect(component.onResize).toHaveBeenCalled();
});
});
describe('Thumbnails', () => {
it('should have own context', () => {
expect(component.pdfThumbnailsContext.viewer).not.toBeNull();
});
it('should open thumbnails panel', () => {
expect(testingUtils.getByCSS('.adf-pdf-viewer__thumbnails')).toBeNull();
component.toggleThumbnails();
fixture.detectChanges();
expect(testingUtils.getByCSS('.adf-pdf-viewer__thumbnails')).not.toBeNull();
});
it('should not render PdfThumbListComponent during initialization of new pdfViewer', () => {
component.toggleThumbnails();
component.urlFile = 'file.pdf';
fixture.detectChanges();
expect(fixture.debugElement.query(By.directive(PdfThumbListComponent))).toBeNull();
});
});
describe('Viewer events', () => {
it('should react on the emit of pageChange event', () => {
const args = {
pageNumber: 6,
source: {
container: document.getElementById(`${component.randomPdfId}-viewer-pdf-viewer`)
}
};
component.onPageChange(args);
expect(component.displayPage).toBe(6);
expect(component.page).toBe(6);
});
it('should react on the emit of pagesLoaded event', () => {
expect(component.isPanelDisabled).toBe(true);
component.onPagesLoaded();
expect(component.isPanelDisabled).toBe(false);
});
});
});

View File

@@ -0,0 +1,640 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable no-underscore-dangle */
/* eslint-disable @angular-eslint/no-output-native */
import { NgIf, NgStyle, NgTemplateOutlet } from '@angular/common';
import {
Component,
EventEmitter,
HostListener,
inject,
InjectionToken,
Input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
TemplateRef,
ViewEncapsulation
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { TranslateModule } from '@ngx-translate/core';
import { from, Subject, switchMap } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AppConfigService, ToolbarComponent, ToolbarDividerComponent } from '@alfresco/adf-core';
import { RenderingQueueServices } from '../../services/rendering-queue.services';
import { PdfPasswordDialogComponent } from '../pdf-viewer-password-dialog/pdf-viewer-password-dialog';
import { PdfThumbListComponent } from '../pdf-viewer-thumbnails/pdf-viewer-thumbnails.component';
import * as pdfjsLib from 'pdfjs-dist/build/pdf.min.mjs';
import { EventBus, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs';
import { OnProgressParameters, PDFDocumentLoadingTask, PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api';
export type PdfScaleMode = 'init' | 'page-actual' | 'page-width' | 'page-height' | 'page-fit' | 'auto';
export const PDFJS_MODULE = new InjectionToken('PDFJS_MODULE', { factory: () => pdfjsLib });
export const PDFJS_VIEWER_MODULE = new InjectionToken('PDFJS_VIEWER_MODULE', { factory: () => PDFViewer });
@Component({
selector: 'adf-pdf-viewer',
standalone: true,
templateUrl: './pdf-viewer.component.html',
styleUrls: ['./pdf-viewer-host.component.scss', './pdf-viewer.component.scss'],
providers: [RenderingQueueServices],
host: { class: 'adf-pdf-viewer' },
imports: [
MatButtonModule,
MatIconModule,
TranslateModule,
PdfThumbListComponent,
NgIf,
NgTemplateOutlet,
MatProgressBarModule,
NgStyle,
ToolbarComponent,
ToolbarDividerComponent
],
encapsulation: ViewEncapsulation.None
})
export class PdfViewerComponent implements OnChanges, OnDestroy {
@Input()
urlFile: string;
@Input()
blobFile: Blob;
@Input()
fileName: string;
@Input()
showToolbar: boolean = true;
@Input()
allowThumbnails = false;
@Input()
thumbnailsTemplate: TemplateRef<any> = null;
@Input()
cacheType: string = '';
@Output()
rendered = new EventEmitter<any>();
@Output()
error = new EventEmitter<any>();
@Output()
close = new EventEmitter<any>();
@Output()
pagesLoaded = new EventEmitter<void>();
page: number;
displayPage: number;
totalPages: number;
loadingPercent: number;
pdfViewer: PDFViewer;
pdfJsWorkerUrl: string;
pdfJsWorkerInstance: Worker;
currentScaleMode: PdfScaleMode = 'init';
MAX_AUTO_SCALE: number = 1.25;
DEFAULT_SCALE_DELTA: number = 1.1;
MIN_SCALE: number = 0.25;
MAX_SCALE: number = 10.0;
loadingTask: PDFDocumentLoadingTask;
isPanelDisabled = true;
showThumbnails: boolean = false;
pdfThumbnailsContext: { viewer: any } = { viewer: null };
randomPdfId: string;
documentOverflow = false;
get currentScaleText(): string {
const currentScaleValueStr = this.pdfViewer?.currentScaleValue;
const scaleNumber = Number(currentScaleValueStr);
const currentScaleText = scaleNumber ? `${Math.round(scaleNumber * 100)}%` : '';
return currentScaleText;
}
private pdfjsLib = inject(PDFJS_MODULE);
private pdfjsViewer = inject(PDFJS_VIEWER_MODULE);
private eventBus = new EventBus();
private pdfjsDefaultOptions = {
disableAutoFetch: true,
disableStream: true,
cMapUrl: './cmaps/',
cMapPacked: true
};
private pdfjsWorkerDestroy$ = new Subject<boolean>();
private dialog = inject(MatDialog);
private renderingQueueServices = inject(RenderingQueueServices);
private appConfigService = inject(AppConfigService);
constructor() {
// needed to preserve "this" context
this.onPageChange = this.onPageChange.bind(this);
this.onPagesLoaded = this.onPagesLoaded.bind(this);
this.onPageRendered = this.onPageRendered.bind(this);
this.randomPdfId = Date.now().toString();
this.pdfjsWorkerDestroy$
.pipe(
catchError(() => null),
switchMap(() => from(this.destroyPfdJsWorker()))
)
.subscribe(() => {});
}
getUserScaling(): number {
let scaleConfig = this.appConfigService.get<number>('adf-viewer.pdf-viewer-scaling', undefined);
if (scaleConfig) {
scaleConfig = scaleConfig / 100;
scaleConfig = this.checkLimits(scaleConfig);
}
return scaleConfig;
}
checkLimits(scaleConfig: number): number {
if (scaleConfig > this.MAX_SCALE) {
return this.MAX_SCALE;
} else if (scaleConfig < this.MIN_SCALE) {
return this.MIN_SCALE;
} else {
return scaleConfig;
}
}
ngOnChanges(changes: SimpleChanges) {
const blobFile = changes['blobFile'];
if (blobFile?.currentValue) {
const reader = new FileReader();
reader.onload = async () => {
const pdfOptions = {
...this.pdfjsDefaultOptions,
data: reader.result,
withCredentials: this.appConfigService.get<boolean>('auth.withCredentials', undefined),
isEvalSupported: false
};
this.executePdf(pdfOptions);
};
reader.readAsArrayBuffer(blobFile.currentValue);
}
const urlFile = changes['urlFile'];
if (urlFile?.currentValue) {
const pdfOptions: any = {
...this.pdfjsDefaultOptions,
url: urlFile.currentValue,
withCredentials: this.appConfigService.get<boolean>('auth.withCredentials', undefined),
isEvalSupported: false
};
if (this.cacheType) {
pdfOptions.httpHeaders = {
'Cache-Control': this.cacheType
};
}
this.executePdf(pdfOptions);
}
if (!this.urlFile && !this.blobFile) {
throw new Error('Attribute urlFile or blobFile is required');
}
}
executePdf(pdfOptions: any) {
this.setupPdfJsWorker().then(() => {
this.loadingTask = this.pdfjsLib.getDocument(pdfOptions);
this.loadingTask.onPassword = (callback, reason) => {
this.onPdfPassword(callback, reason);
};
this.loadingTask.onProgress = (progressData: OnProgressParameters) => {
const level = progressData.loaded / progressData.total;
this.loadingPercent = Math.round(level * 100);
};
this.isPanelDisabled = true;
this.loadingTask.promise
.then((pdfDocument) => {
this.totalPages = pdfDocument.numPages;
this.page = 1;
this.displayPage = 1;
this.initPDFViewer(pdfDocument);
return pdfDocument.getPage(1);
})
.then(() => this.scalePage('init'))
.catch(() => this.error.emit());
});
}
private async setupPdfJsWorker(): Promise<void> {
if (this.pdfJsWorkerInstance) {
await this.destroyPfdJsWorker();
} else if (!this.pdfJsWorkerUrl) {
this.pdfJsWorkerUrl = await this.getPdfJsWorker();
}
this.pdfJsWorkerInstance = new Worker(this.pdfJsWorkerUrl, { type: 'module' });
this.pdfjsLib.GlobalWorkerOptions.workerPort = this.pdfJsWorkerInstance;
}
private async getPdfJsWorker(): Promise<string> {
const response = await fetch('./pdf.worker.min.mjs');
const workerScript = await response.text();
const blob = new Blob([workerScript], { type: 'application/javascript' });
return URL.createObjectURL(blob);
}
initPDFViewer(pdfDocument: PDFDocumentProxy) {
const viewer: any = this.getViewer();
const container = this.getDocumentContainer();
if (viewer && container) {
this.pdfViewer = new this.pdfjsViewer({
container,
viewer,
renderingQueue: this.renderingQueueServices,
eventBus: this.eventBus,
annotationMode: 0
});
// cspell: disable-next
this.eventBus.on('pagechanging', this.onPageChange);
// cspell: disable-next
this.eventBus.on('pagesloaded', this.onPagesLoaded);
// cspell: disable-next
this.eventBus.on('textlayerrendered', () => {
this.onPageRendered();
});
this.eventBus.on('pagerendered', () => {
this.onPageRendered();
});
this.renderingQueueServices.setViewer(this.pdfViewer);
this.pdfViewer.setDocument(pdfDocument);
this.pdfThumbnailsContext.viewer = this.pdfViewer;
}
}
ngOnDestroy() {
if (this.pdfViewer) {
// cspell: disable-next
this.eventBus.off('pagechanging', () => {});
// cspell: disable-next
this.eventBus.off('pagesloaded', () => {});
// cspell: disable-next
this.eventBus.off('textlayerrendered', () => {});
}
if (this.loadingTask) {
this.pdfjsWorkerDestroy$.next(true);
}
this.pdfjsWorkerDestroy$.complete();
this.revokePdfJsWorkerUrl();
}
private async destroyPfdJsWorker() {
if (this.loadingTask.destroy) {
await this.loadingTask.destroy();
}
if (this.pdfJsWorkerInstance) {
this.pdfJsWorkerInstance.terminate();
}
this.loadingTask = null;
}
private revokePdfJsWorkerUrl(): void {
URL.revokeObjectURL(this.pdfJsWorkerUrl);
}
toggleThumbnails() {
this.showThumbnails = !this.showThumbnails;
}
/**
* Method to scale the page current support implementation
*
* @param scaleMode - new scale mode
*/
scalePage(scaleMode: PdfScaleMode) {
this.currentScaleMode = scaleMode;
const viewerContainer = this.getMainContainer();
const documentContainer = this.getDocumentContainer();
if (this.pdfViewer && documentContainer) {
let widthContainer: number;
let heightContainer: number;
if (viewerContainer && viewerContainer.clientWidth <= documentContainer.clientWidth) {
widthContainer = viewerContainer.clientWidth;
heightContainer = viewerContainer.clientHeight;
} else {
widthContainer = documentContainer.clientWidth;
heightContainer = documentContainer.clientHeight;
}
const currentPage = this.pdfViewer._pages[this.pdfViewer._currentPageNumber - 1];
const padding = 20;
const pageWidthScale = ((widthContainer - padding) / currentPage.width) * currentPage.scale;
const pageHeightScale = ((heightContainer - padding) / currentPage.width) * currentPage.scale;
let scale: number;
switch (this.currentScaleMode) {
case 'init':
case 'page-fit': {
scale = this.getUserScaling();
if (!scale) {
scale = this.autoScaling(pageHeightScale, pageWidthScale);
}
break;
}
case 'page-actual': {
scale = 1;
break;
}
case 'page-width': {
scale = pageWidthScale;
break;
}
case 'page-height': {
scale = pageHeightScale;
break;
}
case 'auto': {
scale = this.autoScaling(pageHeightScale, pageWidthScale);
break;
}
default:
return;
}
this.setScaleUpdatePages(scale);
}
}
private autoScaling(pageHeightScale: number, pageWidthScale: number) {
let horizontalScale: number;
if (this.isLandscape) {
horizontalScale = Math.min(pageHeightScale, pageWidthScale);
} else {
horizontalScale = pageWidthScale;
}
horizontalScale = Math.round(horizontalScale);
const scale = Math.min(this.MAX_AUTO_SCALE, horizontalScale);
return this.checkPageFitInContainer(scale);
}
private getMainContainer(): HTMLElement {
return document.getElementById(`${this.randomPdfId}-viewer-main-container`);
}
private getDocumentContainer(): HTMLDivElement {
return document.getElementById(`${this.randomPdfId}-viewer-pdf-viewer`) as HTMLDivElement;
}
private getViewer(): HTMLElement {
return document.getElementById(`${this.randomPdfId}-viewer-viewerPdf`);
}
checkPageFitInContainer(scale: number): number {
const documentContainerSize = this.getDocumentContainer();
const page = this.pdfViewer._pages[this.pdfViewer._currentPageNumber - 1];
if (page.width > documentContainerSize.clientWidth) {
scale = Math.fround((documentContainerSize.clientWidth - 20) / page.width);
if (scale < this.MIN_SCALE) {
scale = this.MIN_SCALE;
}
}
return scale;
}
setDocumentOverflow() {
const documentContainerSize = this.getDocumentContainer();
const page = this.pdfViewer._pages[this.pdfViewer._currentPageNumber - 1];
this.documentOverflow = page.width > documentContainerSize.clientWidth;
}
/**
* Update all the pages with the newScale scale
*
* @param newScale - new scale page
*/
setScaleUpdatePages(newScale: number) {
if (this.pdfViewer) {
if (!this.isSameScale(this.pdfViewer.currentScaleValue, newScale.toString())) {
this.pdfViewer.currentScaleValue = newScale.toString();
}
this.pdfViewer.update();
}
this.setDocumentOverflow();
}
/**
* Check if the request scale of the page is the same for avoid useless re-rendering
*
* @param oldScale - old scale page
* @param newScale - new scale page
* @returns `true` if the scale is the same, otherwise `false`
*/
isSameScale(oldScale: string, newScale: string): boolean {
return newScale === oldScale;
}
/**
* Check if is a land scape view
*
* @param width target width
* @param height target height
* @returns `true` if the target is in the landscape mode, otherwise `false`
*/
isLandscape(width: number, height: number): boolean {
return width > height;
}
/**
* Method triggered when the page is resized
*/
onResize() {
this.scalePage(this.currentScaleMode);
}
/**
* toggle the fit page pdf
*/
pageFit() {
if (this.currentScaleMode !== 'page-fit') {
this.scalePage('page-fit');
} else {
this.scalePage('auto');
}
}
/**
* zoom in page pdf
*
* @param ticks number of ticks to zoom
*/
zoomIn(ticks?: number): void {
let newScale: any = this.pdfViewer.currentScaleValue;
do {
newScale = (newScale * this.DEFAULT_SCALE_DELTA).toFixed(2);
newScale = Math.ceil(newScale * 10) / 10;
newScale = Math.min(this.MAX_SCALE, newScale);
} while (--ticks > 0 && newScale < this.MAX_SCALE);
this.currentScaleMode = 'auto';
this.setScaleUpdatePages(newScale);
}
/**
* zoom out page pdf
*
* @param ticks number of ticks to scale
*/
zoomOut(ticks?: number): void {
let newScale: any = this.pdfViewer.currentScaleValue;
do {
newScale = (newScale / this.DEFAULT_SCALE_DELTA).toFixed(2);
newScale = Math.floor(newScale * 10) / 10;
newScale = Math.max(this.MIN_SCALE, newScale);
} while (--ticks > 0 && newScale > this.MIN_SCALE);
this.currentScaleMode = 'auto';
this.setScaleUpdatePages(newScale);
}
/**
* load the previous page
*/
previousPage() {
if (this.pdfViewer && this.page > 1) {
this.page--;
this.displayPage = this.page;
this.pdfViewer.currentPageNumber = this.page;
}
}
/**
* load the next page
*/
nextPage() {
if (this.pdfViewer && this.page < this.totalPages) {
this.page++;
this.displayPage = this.page;
this.pdfViewer.currentPageNumber = this.page;
}
}
/**
* load the page in input
*
* @param page to load
*/
inputPage(page: string) {
const pageInput = parseInt(page, 10);
if (!isNaN(pageInput) && pageInput > 0 && pageInput <= this.totalPages) {
this.page = pageInput;
this.displayPage = this.page;
this.pdfViewer.currentPageNumber = this.page;
} else {
this.displayPage = this.page;
}
}
/**
* Page Change Event
*
* @param event event
*/
onPageChange(event: any) {
if (event.source && event.source.container.id === `${this.randomPdfId}-viewer-pdf-viewer`) {
this.page = event.pageNumber;
this.displayPage = event.pageNumber;
}
}
onPdfPassword(callback, reason) {
this.dialog
.open(PdfPasswordDialogComponent, {
width: '400px',
data: { reason }
})
.afterClosed()
.subscribe((password) => {
if (password) {
callback(password);
} else {
this.close.emit();
}
});
}
/**
* Page Rendered Event
*/
onPageRendered() {
this.rendered.emit();
}
/**
* Pages Loaded Event
*
*/
onPagesLoaded() {
this.pagesLoaded.emit();
this.isPanelDisabled = false;
}
/**
* Keyboard Event Listener
*
* @param event KeyboardEvent
*/
@HostListener('document:keydown', ['$event'])
handleKeyboardEvent(event: KeyboardEvent) {
const key = event.keyCode;
if (key === 39) {
// right arrow
this.nextPage();
} else if (key === 37) {
// left arrow
this.previousPage();
}
}
}

View File

@@ -0,0 +1,3 @@
<pre class="adf-txt-viewer-content">
{{content}}
</pre>

View File

@@ -0,0 +1,6 @@
.adf-txt-viewer {
background-color: var(--theme-background-color);
overflow: auto;
height: 100%;
width: 100%;
}

View File

@@ -0,0 +1,70 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { SimpleChange } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TxtViewerComponent } from './txt-viewer.component';
import { CoreTestingModule, UnitTestingUtils } from '@alfresco/adf-core';
import { HttpClient } from '@angular/common/http';
import { of } from 'rxjs';
describe('Text View component', () => {
let component: TxtViewerComponent;
let fixture: ComponentFixture<TxtViewerComponent>;
let testingUtils: UnitTestingUtils;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CoreTestingModule, TxtViewerComponent]
});
fixture = TestBed.createComponent(TxtViewerComponent);
testingUtils = new UnitTestingUtils(fixture.debugElement);
const httpClient = TestBed.inject(HttpClient);
spyOn(httpClient, 'get').and.returnValue(of('example'));
component = fixture.componentInstance;
});
describe('View', () => {
it('Should text container be present with urlFile', async () => {
fixture.detectChanges();
const urlFile = './fake-test-file.txt';
const change = new SimpleChange(null, urlFile, true);
await component.ngOnChanges({ urlFile: change });
fixture.detectChanges();
await fixture.whenStable();
expect(testingUtils.getByCSS('.adf-txt-viewer-content').nativeElement.textContent).toContain('example');
});
it('Should text container be present with Blob file', async () => {
const blobFile = new Blob(['text example'], { type: 'text/txt' });
const change = new SimpleChange(null, blobFile, true);
await component.ngOnChanges({ blobFile: change });
fixture.detectChanges();
await fixture.whenStable();
expect(testingUtils.getByCSS('.adf-txt-viewer-content').nativeElement.textContent).toContain('example');
});
});
});

View File

@@ -0,0 +1,91 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { HttpClient } from '@angular/common/http';
import { Component, Input, OnChanges, SimpleChanges, ViewEncapsulation } from '@angular/core';
import { AppConfigService } from '@alfresco/adf-core';
@Component({
selector: 'adf-txt-viewer',
standalone: true,
templateUrl: './txt-viewer.component.html',
styleUrls: ['./txt-viewer.component.scss'],
host: { class: 'adf-txt-viewer' },
encapsulation: ViewEncapsulation.None
})
export class TxtViewerComponent implements OnChanges {
@Input()
urlFile: any;
@Input()
blobFile: Blob;
content: string | ArrayBuffer;
constructor(private http: HttpClient, private appConfigService: AppConfigService) {}
ngOnChanges(changes: SimpleChanges): Promise<void> {
const blobFile = changes['blobFile'];
if (blobFile?.currentValue) {
return this.readBlob(blobFile.currentValue);
}
const urlFile = changes['urlFile'];
if (urlFile?.currentValue) {
return this.getUrlContent(urlFile.currentValue);
}
if (!this.urlFile && !this.blobFile) {
throw new Error('Attribute urlFile or blobFile is required');
}
return Promise.resolve();
}
private getUrlContent(url: string): Promise<void> {
const withCredentialsMode = this.appConfigService.get<boolean>('auth.withCredentials', false);
return new Promise((resolve, reject) => {
this.http.get(url, { responseType: 'text', withCredentials: withCredentialsMode }).subscribe(
(res) => {
this.content = res;
resolve();
},
(event) => {
reject(event);
}
);
});
}
private readBlob(blob: Blob): Promise<void> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
this.content = reader.result;
resolve();
};
reader.onerror = (error: any) => {
reject(error);
};
reader.readAsText(blob);
});
}
}

View File

@@ -0,0 +1,6 @@
<div class="adf-viewer__unknown-format-view">
<div>
<mat-icon class="icon">error</mat-icon>
<div class="adf-viewer__unknown-label">{{ customError || 'ADF_VIEWER.UNKNOWN_FORMAT' | translate }}</div>
</div>
</div>

View File

@@ -0,0 +1,9 @@
.adf-viewer__unknown-format-view {
height: 90vh;
text-align: center;
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
color: var(--adf-theme-foreground-text-color);
}

View File

@@ -0,0 +1,46 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { UnknownFormatComponent } from './unknown-format.component';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CoreTestingModule } from '@alfresco/adf-core';
describe('Unknown Format Component', () => {
let fixture: ComponentFixture<UnknownFormatComponent>;
const getErrorMessageElement = (): string => fixture.debugElement.nativeElement.querySelector('.adf-viewer__unknown-label').innerText;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CoreTestingModule]
});
fixture = TestBed.createComponent(UnknownFormatComponent);
fixture.detectChanges();
});
it('should render default error message', () => {
expect(getErrorMessageElement()).toBe('ADF_VIEWER.UNKNOWN_FORMAT');
});
it('should render custom error message if such provided', () => {
const errorMessage = 'Custom error message';
fixture.componentInstance.customError = errorMessage;
fixture.detectChanges();
expect(getErrorMessageElement()).toBe(errorMessage);
});
});

View File

@@ -0,0 +1,34 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, Input, ViewEncapsulation } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'adf-viewer-unknown-format',
standalone: true,
templateUrl: './unknown-format.component.html',
styleUrls: ['./unknown-format.component.scss'],
imports: [MatIconModule, TranslateModule],
encapsulation: ViewEncapsulation.None
})
export class UnknownFormatComponent {
/** Custom error message to be displayed . */
@Input()
customError: string;
}

View File

@@ -0,0 +1,28 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
@Component({
selector: 'adf-viewer-more-actions',
standalone: true,
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'adf-viewer-more-actions' },
template: `<ng-content />`
})
export class ViewerMoreActionsComponent {}

View File

@@ -0,0 +1,28 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
@Component({
selector: 'adf-viewer-open-with',
standalone: true,
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'adf-viewer-open-with' },
template: `<ng-content />`
})
export class ViewerOpenWithComponent {}

View File

@@ -0,0 +1,105 @@
<div *ngIf="isLoading$ | async" class="adf-viewer-render-main-loader">
<div class="adf-viewer-render-layout-content adf-viewer__fullscreen-container">
<div class="adf-viewer-render-content-container">
<div class="adf-viewer-render__loading-screen">
<h2>{{ 'ADF_VIEWER.LOADING' | translate }}</h2>
<div>
<mat-spinner class="adf-viewer-render__loading-screen__spinner" />
</div>
</div>
</div>
</div>
</div>
<ng-container *ngIf="urlFile || blobFile">
<div [hidden]="isLoading$ | async" class="adf-viewer-render-main">
<div class="adf-viewer-render-layout-content adf-viewer__fullscreen-container">
<div class="adf-viewer-render-content-container" [ngSwitch]="viewerType">
<ng-container *ngSwitchCase="'external'">
<adf-preview-extension
*ngIf="!!externalViewer"
[id]="externalViewer.component"
[url]="urlFile"
[extension]="externalViewer.fileExtension"
[nodeId]="nodeId"
[attr.data-automation-id]="externalViewer.component"
/>
</ng-container>
<ng-container *ngSwitchCase="'pdf'">
<adf-pdf-viewer
[thumbnailsTemplate]="thumbnailsTemplate"
[allowThumbnails]="allowThumbnails"
[blobFile]="blobFile"
[urlFile]="urlFile"
[fileName]="internalFileName"
[cacheType]="cacheTypeForContent"
(pagesLoaded)="markAsLoaded()"
(close)="onClose()"
(error)="onUnsupportedFile()"
/>
</ng-container>
<ng-container *ngSwitchCase="'image'">
<adf-img-viewer
[urlFile]="urlFile"
[readOnly]="readOnly"
[fileName]="internalFileName"
[allowedEditActions]="allowedEditActions"
[blobFile]="blobFile"
(error)="onUnsupportedFile()"
(submit)="onSubmitFile($event)"
(imageLoaded)="markAsLoaded()"
(isSaving)="isSaving.emit($event)"
/>
</ng-container>
<ng-container *ngSwitchCase="'media'">
<adf-media-player
id="adf-mdedia-player"
[urlFile]="urlFile"
[tracks]="tracks"
[mimeType]="mimeType"
[blobFile]="blobFile"
[fileName]="internalFileName"
(error)="onUnsupportedFile()"
(canPlay)="markAsLoaded()"
/>
</ng-container>
<ng-container *ngSwitchCase="'text'">
<adf-txt-viewer [urlFile]="urlFile" [blobFile]="blobFile" />
</ng-container>
<ng-container *ngSwitchCase="'custom'">
<ng-container *ngFor="let ext of viewerExtensions">
<adf-preview-extension
*ngIf="checkExtensions(ext.fileExtension)"
[id]="ext.component"
[url]="urlFile"
[extension]="extension"
[nodeId]="nodeId"
[attr.data-automation-id]="ext.component"
/>
</ng-container>
<ng-container *ngFor="let extensionTemplate of extensionTemplates">
<span *ngIf="extensionTemplate.isVisible" class="adf-viewer-render-custom-content">
<ng-template
[ngTemplateOutlet]="extensionTemplate.template"
[ngTemplateOutletContext]="{ urlFile: urlFile, extension: extension }"
/>
</span>
</ng-container>
</ng-container>
<ng-container *ngSwitchDefault>
<adf-viewer-unknown-format [customError]="customError" />
</ng-container>
</div>
</div>
</div>
</ng-container>
<ng-container *ngIf="viewerTemplateExtensions">
<ng-template [ngTemplateOutlet]="viewerTemplateExtensions" [ngTemplateOutletInjector]="injector" />
</ng-container>

View File

@@ -0,0 +1,88 @@
/* stylelint-disable scss/at-extend-no-missing-placeholder */
.adf-full-screen {
width: 100%;
height: 100%;
background-color: var(--adf-theme-background-card-color);
}
.adf-viewer-render-main-loader {
position: fixed;
top: 64px;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.adf-viewer-render {
&-main {
width: 0;
order: 1;
flex: 1 1 auto;
}
&-content-container {
display: flex;
justify-content: center;
}
&-layout-content {
@extend .adf-full-screen;
position: relative;
overflow: hidden;
z-index: 1;
background-color: var(--theme-background-color);
display: flex;
flex-flow: row wrap;
flex: 1;
& > div {
display: flex;
flex-flow: row wrap;
margin: 0 auto;
align-items: stretch;
height: 93vh;
width: 100%;
}
}
&-overlay-container {
.adf-viewer-render-content {
position: fixed;
top: 0;
left: 0;
z-index: 1000;
}
}
&__loading-screen {
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: center;
flex-direction: column;
height: 85vh;
&__spinner {
margin: 0 auto;
}
}
&-custom-content {
width: 100vw;
}
&-unknown-content {
align-items: center;
display: flex;
}
&-pdf {
display: contents;
}
}

View File

@@ -0,0 +1,540 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AppExtensionService, ViewerExtensionRef } from '@alfresco/adf-extensions';
import { Location } from '@angular/common';
import { SpyLocation } from '@angular/common/testing';
import { Component, DebugElement, TemplateRef, ViewChild } from '@angular/core';
import { ComponentFixture, DeferBlockBehavior, TestBed } from '@angular/core/testing';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { NoopTranslateModule, UnitTestingUtils } from '../../../testing';
import { RenderingQueueServices } from '../../services/rendering-queue.services';
import { ViewerRenderComponent } from './viewer-render.component';
import { ImgViewerComponent, MediaPlayerComponent, PdfViewerComponent, ViewerExtensionDirective } from '@alfresco/adf-core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
@Component({
selector: 'adf-double-viewer',
standalone: true,
imports: [ViewerExtensionDirective, ViewerRenderComponent],
template: `
<adf-viewer-render [urlFile]="urlFileViewer1" [viewerTemplateExtensions]="viewerTemplateExtensions" #viewer1 />
<adf-viewer-render [urlFile]="urlFileViewer2" #viewer2 />
<ng-template #viewerExtension>
<adf-viewer-extension [supportedExtensions]="['json']">
<ng-template>
<h1>JSON Viewer</h1>
</ng-template>
</adf-viewer-extension>
<adf-viewer-extension [supportedExtensions]="['test']">
<ng-template>
<h1>Test Viewer</h1>
</ng-template>
</adf-viewer-extension>
</ng-template>
`
})
class DoubleViewerComponent {
@ViewChild('viewer1')
viewer1: ViewerRenderComponent;
@ViewChild('viewer2')
viewer2: ViewerRenderComponent;
@ViewChild('viewerExtension', { static: true })
viewerTemplateExtensions: TemplateRef<any>;
urlFileViewer1: string;
urlFileViewer2: string;
}
describe('ViewerComponent', () => {
let component: ViewerRenderComponent;
let fixture: ComponentFixture<ViewerRenderComponent>;
let extensionService: AppExtensionService;
let testingUtils: UnitTestingUtils;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [NoopTranslateModule, NoopAnimationsModule, MatDialogModule, ViewerRenderComponent, DoubleViewerComponent],
providers: [RenderingQueueServices, { provide: Location, useClass: SpyLocation }, MatDialog],
deferBlockBehavior: DeferBlockBehavior.Playthrough
});
fixture = TestBed.createComponent(ViewerRenderComponent);
testingUtils = new UnitTestingUtils(fixture.debugElement);
component = fixture.componentInstance;
extensionService = TestBed.inject(AppExtensionService);
await fixture.whenStable();
});
afterEach(() => {
fixture.destroy();
});
describe('Double viewer Test', () => {
it('should not reload the content of all the viewer after type change', async () => {
const fixtureDouble = TestBed.createComponent(DoubleViewerComponent);
fixtureDouble.componentInstance.urlFileViewer1 = 'fake-test-file.pdf';
fixtureDouble.componentInstance.urlFileViewer2 = 'fake-test-file-two.xls';
fixtureDouble.detectChanges();
await fixtureDouble.whenStable();
fixtureDouble.componentInstance.viewer1.ngOnChanges();
fixtureDouble.componentInstance.viewer2.ngOnChanges();
fixtureDouble.detectChanges();
await fixtureDouble.whenStable();
expect(fixtureDouble.componentInstance.viewer1.viewerType).toBe('pdf');
expect(fixtureDouble.componentInstance.viewer2.viewerType).toBe('unknown');
fixtureDouble.componentInstance.urlFileViewer1 = 'fake-test-file.pdf';
fixtureDouble.componentInstance.urlFileViewer2 = 'fake-test-file-two.png';
fixtureDouble.detectChanges();
await fixtureDouble.whenStable();
fixtureDouble.componentInstance.viewer1.ngOnChanges();
fixtureDouble.componentInstance.viewer2.ngOnChanges();
expect(fixtureDouble.componentInstance.viewer1.viewerType).toBe('pdf');
expect(fixtureDouble.componentInstance.viewer2.viewerType).toBe('image');
});
});
describe('Extension Type Test', () => {
it('should display pdf external viewer via wildcard notation', async () => {
const extension: ViewerExtensionRef = {
component: 'custom.component',
id: 'custom.component.id',
fileExtension: '*'
};
spyOn(extensionService, 'getViewerExtensions').and.returnValue([extension]);
fixture = TestBed.createComponent(ViewerRenderComponent);
testingUtils.setDebugElement(fixture.debugElement);
component = fixture.componentInstance;
component.urlFile = 'fake-test-file.pdf';
component.ngOnChanges();
fixture.detectChanges();
await fixture.whenStable();
expect(component.externalExtensions.includes('*')).toBe(true);
expect(component.externalViewer).toBe(extension);
expect(component.viewerType).toBe('external');
expect(testingUtils.getByDataAutomationId('custom.component')).not.toBeNull();
});
it('should display pdf with the first external viewer provided', async () => {
const extensions: ViewerExtensionRef[] = [
{
component: 'custom.component.1',
id: 'custom.component.id',
fileExtension: '*'
},
{
component: 'custom.component.2',
id: 'custom.component.id',
fileExtension: '*'
}
];
spyOn(extensionService, 'getViewerExtensions').and.returnValue(extensions);
fixture = TestBed.createComponent(ViewerRenderComponent);
testingUtils.setDebugElement(fixture.debugElement);
component = fixture.componentInstance;
component.urlFile = 'fake-test-file.pdf';
component.ngOnChanges();
fixture.detectChanges();
await fixture.whenStable();
expect(testingUtils.getByDataAutomationId('custom.component.1')).not.toBeNull();
expect(testingUtils.getByDataAutomationId('custom.component.2')).toBeNull();
});
it('should display url with the external viewer provided', async () => {
const extension: ViewerExtensionRef = {
component: 'custom.component',
id: 'custom.component.id',
fileExtension: '*'
};
spyOn(extensionService, 'getViewerExtensions').and.returnValue([extension]);
fixture = TestBed.createComponent(ViewerRenderComponent);
testingUtils.setDebugElement(fixture.debugElement);
component = fixture.componentInstance;
component.urlFile = 'http://localhost:4200/alfresco';
component.ngOnChanges();
fixture.detectChanges();
await fixture.whenStable();
expect(component.externalExtensions.includes('*')).toBe(true);
expect(component.externalViewer).toBe(extension);
expect(component.viewerType).toBe('external');
expect(testingUtils.getByDataAutomationId('custom.component')).not.toBeNull();
});
it('should extension file pdf be loaded', (done) => {
component.urlFile = 'fake-test-file.pdf';
fixture.detectChanges();
component.ngOnChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(testingUtils.getByCSS('adf-pdf-viewer')).not.toBeNull();
done();
});
});
it('should extension file png be loaded', (done) => {
component.urlFile = 'fake-url-file.png';
fixture.detectChanges();
component.ngOnChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(testingUtils.getByCSS('#viewer-image')).not.toBeNull();
done();
});
});
it('should extension file mp4 be loaded', (done) => {
component.urlFile = 'fake-url-file.mp4';
component.ngOnChanges();
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(testingUtils.getByCSS('adf-media-player')).not.toBeNull();
done();
});
});
it('should extension file txt be loaded', (done) => {
component.urlFile = 'fake-test-file.txt';
component.ngOnChanges();
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(testingUtils.getByCSS('adf-txt-viewer')).not.toBeNull();
done();
});
});
it('should display [unknown format] for unsupported extensions', (done) => {
component.urlFile = 'fake-url-file.unsupported';
component.mimeType = '';
component.ngOnChanges();
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(testingUtils.getByCSS('adf-viewer-unknown-format')).toBeDefined();
done();
});
});
});
describe('Custom viewer extension template', () => {
const getCustomViewerContent = (customFixture: ComponentFixture<DoubleViewerComponent>): HTMLHeadingElement => {
testingUtils.setDebugElement(customFixture.debugElement);
return testingUtils.getByCSS('.adf-viewer-render-custom-content h1').nativeElement;
};
it('should render provided custom template when file type matches supported extensions', async () => {
const fixtureCustom = TestBed.createComponent(DoubleViewerComponent);
fixtureCustom.detectChanges();
await fixtureCustom.whenStable();
const customComponent = fixtureCustom.componentInstance.viewer1;
fixtureCustom.componentInstance.urlFileViewer1 = 'fake-url-file.json';
customComponent.ngOnChanges();
fixtureCustom.detectChanges();
await fixtureCustom.whenStable();
let customContent = getCustomViewerContent(fixtureCustom);
expect(customComponent.extensionsSupportedByTemplates).toEqual(['json', 'test']);
expect(customComponent.extensionTemplates.length).toBe(2);
expect(customComponent.extensionTemplates[0].isVisible).toBeTrue();
expect(customComponent.extensionTemplates[1].isVisible).toBeFalse();
expect(customContent.innerText).toBe('JSON Viewer');
fixtureCustom.componentInstance.urlFileViewer1 = 'fake-url-file.test';
customComponent.ngOnChanges();
fixtureCustom.detectChanges();
await fixtureCustom.whenStable();
customContent = getCustomViewerContent(fixtureCustom);
expect(customComponent.extensionTemplates[0].isVisible).toBeFalse();
expect(customComponent.extensionTemplates[1].isVisible).toBeTrue();
expect(customContent.innerText).toBe('Test Viewer');
});
});
describe('MimeType handling', () => {
it('should display an image file identified by mimetype when the filename has no extension', (done) => {
component.urlFile = 'fake-content-img';
component.mimeType = 'image/png';
fixture.detectChanges();
component.ngOnChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(testingUtils.getByCSS('#viewer-image')).not.toBeNull();
done();
});
});
it('should display a image file identified by mimetype when the file extension is wrong', (done) => {
component.urlFile = 'fake-content-img.bin';
component.mimeType = 'image/png';
fixture.detectChanges();
component.ngOnChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(testingUtils.getByCSS('#viewer-image')).not.toBeNull();
done();
});
});
it('should display the txt viewer if the file identified by mimetype is a txt when the filename has wrong extension', (done) => {
component.urlFile = 'fake-content-txt.bin';
component.mimeType = 'text/plain';
fixture.detectChanges();
component.ngOnChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(testingUtils.getByCSS('adf-txt-viewer')).not.toBeNull();
done();
});
});
it('should display the media player if the file identified by mimetype is a media when the filename has wrong extension', (done) => {
component.urlFile = 'fake-content-video.bin';
component.mimeType = 'video/mp4';
fixture.detectChanges();
component.ngOnChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(testingUtils.getByCSS('adf-media-player')).not.toBeNull();
done();
});
}, 25000);
it('should display the media player if the file identified by mimetype is a media when the filename has no extension', (done) => {
component.urlFile = 'fake-content-video';
component.mimeType = 'video/mp4';
fixture.detectChanges();
component.ngOnChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(testingUtils.getByCSS('adf-media-player')).not.toBeNull();
done();
});
}, 25000);
it('should display a PDF file identified by mimetype when the filename has no extension', (done) => {
component.urlFile = 'fake-content-pdf';
component.mimeType = 'application/pdf';
fixture.detectChanges();
component.ngOnChanges();
fixture.getDeferBlocks().then(() => {
fixture.detectChanges();
expect(testingUtils.getByCSS('adf-pdf-viewer')).not.toBeNull();
done();
});
}, 25000);
it('should display a PDF file identified by mimetype when the file extension is wrong', (done) => {
component.urlFile = 'fake-content-pdf.bin';
component.mimeType = 'application/pdf';
fixture.detectChanges();
component.ngOnChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(testingUtils.getByCSS('adf-pdf-viewer')).not.toBeNull();
done();
});
}, 25000);
});
describe('Base component', () => {
beforeEach(() => {
component.urlFile = 'fake-test-file.pdf';
component.mimeType = 'application/pdf';
fixture.detectChanges();
});
it('should emit new value when isSaving emits new event', () => {
spyOn(component.isSaving, 'emit');
component.urlFile = 'fake-url-file.png';
component.ngOnChanges();
fixture.detectChanges();
const imgViewer = testingUtils.getByCSS('adf-img-viewer');
imgViewer.triggerEventHandler('isSaving', true);
expect(component.isSaving.emit).toHaveBeenCalledWith(true);
});
describe('Attribute', () => {
it('should urlFile present not thrown any error ', () => {
expect(() => {
component.ngOnChanges();
}).not.toThrow();
});
});
describe('error handling', () => {
it('should switch to the unknown template if the type specific viewers throw an error', (done) => {
component.urlFile = 'fake-url-file.icns';
component.mimeType = 'image/png';
component.ngOnChanges();
fixture.detectChanges();
component.onUnsupportedFile();
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(testingUtils.getByCSS('adf-viewer-unknown-format')).toBeDefined();
done();
});
});
});
describe('Events', () => {
it('should if the extension change extension Change event be fired ', (done) => {
component.extensionChange.subscribe((fileExtension) => {
expect(fileExtension).toEqual('png');
done();
});
component.urlFile = 'fake-url-file.png';
component.ngOnChanges();
});
});
describe('display name property override by urlFile', () => {
it('should fileName override the default name if is present and urlFile is set', () => {
component.urlFile = 'fake-test-file.pdf';
component.fileName = 'test name';
fixture.detectChanges();
component.ngOnChanges();
expect(component.internalFileName).toEqual('test name');
});
it('should use the urlFile name if fileName is NOT set and urlFile is set', () => {
component.urlFile = 'fake-test-file.pdf';
component.fileName = '';
fixture.detectChanges();
component.ngOnChanges();
expect(component.internalFileName).toEqual('fake-test-file.pdf');
});
});
describe('display name property override by blobFile', () => {
it('should fileName override the name if is present and blobFile is set', () => {
component.fileName = 'blob file display name';
component.blobFile = new Blob(['This is my blob content'], { type: 'text/plain' });
fixture.detectChanges();
component.ngOnChanges();
expect(component.internalFileName).toEqual('blob file display name');
});
});
});
describe('Spinner', () => {
const getMainLoader = (): DebugElement => testingUtils.getByCSS('.adf-viewer-render-main-loader');
it('should not show spinner by default', (done) => {
component.isLoading$.subscribe((isLoading) => {
fixture.detectChanges();
expect(isLoading).toBeFalse();
expect(getMainLoader()).toBeNull();
done();
});
});
it('should display spinner when viewerType is media', () => {
component.urlFile = 'some-file.mp4';
component.ngOnChanges();
fixture.detectChanges();
expect(getMainLoader()).not.toBeNull();
const mediaViewer = testingUtils.getByDirective(MediaPlayerComponent);
mediaViewer.triggerEventHandler('canPlay', null);
fixture.detectChanges();
expect(getMainLoader()).toBeNull();
expect(component.viewerType).toBe('media');
});
it('should display spinner when viewerType is pdf', () => {
component.urlFile = 'some-url.pdf';
expect(getMainLoader()).toBeNull();
component.ngOnChanges();
fixture.detectChanges();
const imgViewer = testingUtils.getByDirective(PdfViewerComponent);
imgViewer.triggerEventHandler('pagesLoaded', null);
fixture.detectChanges();
expect(component.viewerType).toBe('pdf');
});
it('should display spinner when viewerType is image', () => {
component.urlFile = 'some-url.png';
component.ngOnChanges();
fixture.detectChanges();
expect(getMainLoader()).not.toBeNull();
const imgViewer = testingUtils.getByDirective(ImgViewerComponent);
imgViewer.triggerEventHandler('imageLoaded', null);
fixture.detectChanges();
expect(getMainLoader()).toBeNull();
expect(component.viewerType).toBe('image');
});
});
});

View File

@@ -0,0 +1,250 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AppExtensionService, ExtensionsModule, ViewerExtensionRef } from '@alfresco/adf-extensions';
import { AsyncPipe, NgForOf, NgIf, NgSwitch, NgSwitchCase, NgSwitchDefault, NgTemplateOutlet } from '@angular/common';
import { Component, EventEmitter, Injector, Input, OnChanges, OnInit, Output, TemplateRef, ViewEncapsulation } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslateModule } from '@ngx-translate/core';
import { Track } from '../../models/viewer.model';
import { ViewUtilService } from '../../services/view-util.service';
import { ImgViewerComponent } from '../img-viewer/img-viewer.component';
import { MediaPlayerComponent } from '../media-player/media-player.component';
import { PdfViewerComponent } from '../pdf-viewer/pdf-viewer.component';
import { TxtViewerComponent } from '../txt-viewer/txt-viewer.component';
import { UnknownFormatComponent } from '../unknown-format/unknown-format.component';
import { BehaviorSubject } from 'rxjs';
type ViewerType = 'media' | 'image' | 'pdf' | 'external' | 'text' | 'custom' | 'unknown';
@Component({
selector: 'adf-viewer-render',
standalone: true,
templateUrl: './viewer-render.component.html',
styleUrls: ['./viewer-render.component.scss'],
host: { class: 'adf-viewer-render' },
encapsulation: ViewEncapsulation.None,
imports: [
TranslateModule,
MatProgressSpinnerModule,
NgSwitch,
NgSwitchCase,
NgIf,
PdfViewerComponent,
ImgViewerComponent,
MediaPlayerComponent,
TxtViewerComponent,
NgTemplateOutlet,
UnknownFormatComponent,
ExtensionsModule,
NgForOf,
NgSwitchDefault,
AsyncPipe
],
providers: [ViewUtilService]
})
export class ViewerRenderComponent implements OnChanges, OnInit {
/**
* If you want to load an external file that does not come from ACS you
* can use this URL to specify where to load the file from.
*/
@Input()
urlFile = '';
/** Loads a Blob File */
@Input()
blobFile: Blob;
/** Toggles the 'Full Screen' feature. */
@Input()
allowFullScreen = true;
/** Toggles PDF thumbnails. */
@Input()
allowThumbnails = true;
/** The template for the pdf thumbnails. */
@Input()
thumbnailsTemplate: TemplateRef<any> = null;
/** MIME type of the file content (when not determined by the filename extension). */
@Input()
mimeType: string;
/** Override Content filename. */
@Input()
fileName: string;
/** Enable when where is possible the editing functionalities */
@Input()
readOnly = true;
/**
* Controls which actions are enabled in the viewer.
* Example:
* { rotate: true, crop: false } will enable rotation but disable cropping.
*/
@Input()
allowedEditActions: { [key: string]: boolean } = {
rotate: true,
crop: true
};
/** media subtitles for the media player*/
@Input()
tracks: Track[] = [];
/** Identifier of a node that is opened by the viewer. */
@Input()
nodeId: string = null;
/** Template containing ViewerExtensionDirective instances providing different viewer extensions based on supported file extension. */
@Input()
viewerTemplateExtensions: TemplateRef<any>;
/** Custom error message to be displayed in the viewer. */
@Input()
customError: string = undefined;
/** Emitted when the filename extension changes. */
@Output()
extensionChange = new EventEmitter<string>();
/** Emitted when the img is submitted in the img viewer. */
@Output()
submitFile = new EventEmitter<Blob>();
/** Emitted when the img is submitted in the img viewer. */
@Output()
close = new EventEmitter<boolean>();
/** Emitted when the img is saving. */
@Output()
isSaving = new EventEmitter<boolean>();
extensionTemplates: { template: TemplateRef<any>; isVisible: boolean }[] = [];
extensionsSupportedByTemplates: string[] = [];
extension: string;
internalFileName: string;
viewerType: ViewerType = 'unknown';
readonly isLoading$ = new BehaviorSubject(false);
/**
* Returns a list of the active Viewer content extensions.
*
* @returns list of extension references
*/
get viewerExtensions(): ViewerExtensionRef[] {
return this.extensionService.getViewerExtensions();
}
/**
* Provides a list of file extensions supported by external plugins.
*
* @returns list of extensions
*/
get externalExtensions(): string[] {
return this.viewerExtensions.map((ext) => ext.fileExtension);
}
private _externalViewer: ViewerExtensionRef;
get externalViewer(): ViewerExtensionRef {
if (!this._externalViewer) {
this._externalViewer = this.viewerExtensions.find((ext) => ext.fileExtension === '*');
}
return this._externalViewer;
}
cacheTypeForContent = 'no-cache';
constructor(
private viewUtilService: ViewUtilService,
private extensionService: AppExtensionService,
public dialog: MatDialog,
public readonly injector: Injector
) {}
ngOnInit() {
this.cacheTypeForContent = 'no-cache';
this.setDefaultLoadingState();
}
ngOnChanges() {
if (this.blobFile) {
this.setUpBlobData();
} else if (this.urlFile) {
this.setUpUrlFile();
}
}
markAsLoaded() {
this.isLoading$.next(false);
}
private setUpBlobData() {
this.internalFileName = this.fileName;
this.viewerType = this.viewUtilService.getViewerTypeByMimeType(this.blobFile.type) as ViewerType;
this.extensionChange.emit(this.blobFile.type);
this.scrollTop();
}
private setUpUrlFile() {
this.internalFileName = this.fileName ? this.fileName : this.viewUtilService.getFilenameFromUrl(this.urlFile);
this.extension = this.viewUtilService.getFileExtension(this.internalFileName);
this.viewerType = this.viewUtilService.getViewerType(this.extension, this.mimeType, this.extensionsSupportedByTemplates) as ViewerType;
this.extensionChange.emit(this.extension);
this.scrollTop();
}
scrollTop() {
window.scrollTo(0, 1);
}
checkExtensions(extensionAllowed) {
if (typeof extensionAllowed === 'string') {
return this.extension.toLowerCase() === extensionAllowed.toLowerCase();
} else if (extensionAllowed.length > 0) {
return extensionAllowed.find((currentExtension) => this.extension.toLowerCase() === currentExtension.toLowerCase());
}
}
onSubmitFile(newImageBlob: Blob) {
this.submitFile.next(newImageBlob);
}
onUnsupportedFile() {
this.viewerType = 'unknown';
}
onClose() {
this.close.next(true);
}
private canBePreviewed(): boolean {
return this.viewerType === 'media' || this.viewerType === 'pdf' || this.viewerType === 'image';
}
private setDefaultLoadingState() {
if (this.canBePreviewed()) {
this.isLoading$.next(true);
}
}
}

View File

@@ -0,0 +1,38 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ChangeDetectionStrategy, Component, HostListener, ViewEncapsulation } from '@angular/core';
@Component({
selector: 'adf-viewer-sidebar',
standalone: true,
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'adf-viewer-sidebar' },
template: `<ng-content />`
})
export class ViewerSidebarComponent {
@HostListener('keydown', ['$event'])
onKeyDown(event: KeyboardEvent): void {
event.stopPropagation();
}
@HostListener('keyup', ['$event'])
onKeyUp(event: KeyboardEvent): void {
event.stopPropagation();
}
}

View File

@@ -0,0 +1,28 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
@Component({
selector: 'adf-viewer-toolbar-actions',
standalone: true,
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'adf-viewer-toolbar-actions' },
template: `<ng-content />`
})
export class ViewerToolbarActionsComponent {}

View File

@@ -0,0 +1,28 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
@Component({
selector: 'adf-viewer-toolbar-custom-actions',
standalone: true,
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'adf-viewer-toolbar-custom-actions' },
template: `<ng-content />`
})
export class ViewerToolbarCustomActionsComponent {}

View File

@@ -0,0 +1,28 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
@Component({
selector: 'adf-viewer-toolbar',
standalone: true,
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'adf-viewer-toolbar' },
template: `<ng-content />`
})
export class ViewerToolbarComponent {}

View File

@@ -0,0 +1,182 @@
<div *ngIf="showViewer"
class="adf-viewer-container"
[class.adf-viewer-overlay-container]="overlayMode"
[class.adf-viewer-inline-container]="!overlayMode">
<div class="adf-viewer-content"
[cdkTrapFocus]="overlayMode"
cdkTrapFocusAutoCapture>
<ng-content select="adf-viewer-toolbar" />
<ng-container *ngIf="showToolbar && !toolbar">
<adf-toolbar id="adf-viewer-toolbar" class="adf-viewer-toolbar">
<adf-toolbar-title>
<ng-container *ngIf="allowLeftSidebar">
<button mat-icon-button
[attr.aria-expanded]="showLeftSidebar"
[attr.aria-label]="'ADF_VIEWER.ACTIONS.INFO' | translate"
title="{{ 'ADF_VIEWER.ACTIONS.INFO' | translate }}"
data-automation-id="adf-toolbar-left-sidebar"
[color]="showLeftSidebar ? 'accent' : null"
(click)="toggleLeftSidebar()">
<mat-icon>info_outline</mat-icon>
</button>
</ng-container>
<button *ngIf="allowGoBack && closeButtonPosition === CloseButtonPosition.Left"
class="adf-viewer-close-button"
data-automation-id="adf-toolbar-left-back"
[attr.aria-label]="'ADF_VIEWER.ACTIONS.CLOSE' | translate"
mat-icon-button
title="{{ 'ADF_VIEWER.ACTIONS.CLOSE' | translate }}"
(click)="onClose()">
<mat-icon>close</mat-icon>
</button>
</adf-toolbar-title>
<div class="adf-viewer__file-title">
<button *ngIf="allowNavigate && canNavigateBefore"
data-automation-id="adf-toolbar-pref-file"
mat-icon-button
[attr.aria-label]="'ADF_VIEWER.ACTIONS.PREV_FILE' | translate"
title="{{ 'ADF_VIEWER.ACTIONS.PREV_FILE' | translate }}"
(click)="onNavigateBeforeClick($event)">
<mat-icon>navigate_before</mat-icon>
</button>
<img class="adf-viewer__mimeicon"
[alt]="'ADF_VIEWER.ARIA.MIME_TYPE_ICON' | translate"
[src]="mimeTypeIconUrl"
data-automation-id="adf-file-thumbnail">
<div class="adf-viewer__display-name"
id="adf-viewer-display-name"
[title]="fileName">
<span class="adf-viewer__display-name-without-extension">{{ fileNameWithoutExtension }}</span>
<span class="adf-viewer__display-name-extension">{{ fileExtension }}</span>
</div>
<button *ngIf="allowNavigate && canNavigateNext"
data-automation-id="adf-toolbar-next-file"
mat-icon-button
[attr.aria-label]="'ADF_VIEWER.ACTIONS.NEXT_FILE' | translate"
title="{{ 'ADF_VIEWER.ACTIONS.NEXT_FILE' | translate }}"
(click)="onNavigateNextClick($event)">
<mat-icon>navigate_next</mat-icon>
</button>
</div>
<ng-content select="adf-viewer-toolbar-actions" />
<ng-container *ngIf="mnuOpenWith"
data-automation-id='adf-toolbar-custom-btn'>
<button id="adf-viewer-openwith"
mat-button
[matMenuTriggerFor]="mnuOpenWith"
data-automation-id="adf-toolbar-open-with">
<span>{{ 'ADF_VIEWER.ACTIONS.OPEN_WITH' | translate }}</span>
<mat-icon>arrow_drop_down</mat-icon>
</button>
<mat-menu #mnuOpenWith="matMenu"
[overlapTrigger]="false">
<ng-content select="adf-viewer-open-with" />
</mat-menu>
</ng-container>
<adf-toolbar-divider />
<ng-content select="adf-viewer-toolbar-custom-actions" />
<button id="adf-viewer-fullscreen"
*ngIf="allowFullScreen"
mat-icon-button
[attr.aria-label]="'ADF_VIEWER.ACTIONS.FULLSCREEN' | translate"
title="{{ 'ADF_VIEWER.ACTIONS.FULLSCREEN' | translate }}"
data-automation-id="adf-toolbar-fullscreen"
(click)="enterFullScreen()">
<mat-icon>fullscreen</mat-icon>
</button>
<ng-container *ngIf="allowRightSidebar && !hideInfoButton">
<adf-toolbar-divider />
<button mat-icon-button
[attr.aria-expanded]="showRightSidebar"
[attr.aria-label]="'ADF_VIEWER.ACTIONS.INFO' | translate"
title="{{ 'ADF_VIEWER.ACTIONS.INFO' | translate }}"
data-automation-id="adf-toolbar-sidebar"
[color]="showRightSidebar ? 'accent' : null"
(click)="toggleRightSidebar()">
<mat-icon>info_outline</mat-icon>
</button>
</ng-container>
<ng-container *ngIf="mnuMoreActions">
<button id="adf-viewer-moreactions"
mat-icon-button
[matMenuTriggerFor]="mnuMoreActions"
[attr.aria-label]="'ADF_VIEWER.ACTIONS.MORE_ACTIONS' | translate"
title="{{ 'ADF_VIEWER.ACTIONS.MORE_ACTIONS' | translate }}"
data-automation-id="adf-toolbar-more-actions">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #mnuMoreActions="matMenu"
[overlapTrigger]="false">
<ng-content select="adf-viewer-more-actions" />
</mat-menu>
</ng-container>
<ng-container *ngIf="allowGoBack && closeButtonPosition === CloseButtonPosition.Right">
<adf-toolbar-divider />
<button class="adf-viewer-close-button"
data-automation-id="adf-toolbar-right-back"
[attr.aria-label]="'ADF_VIEWER.ACTIONS.CLOSE' | translate"
mat-icon-button
title="{{ 'ADF_VIEWER.ACTIONS.CLOSE' | translate }}"
(click)="onClose()">
<mat-icon>close</mat-icon>
</button>
</ng-container>
</adf-toolbar>
</ng-container>
<div class="adf-viewer-sidebars">
<ng-container *ngIf="allowRightSidebar && showRightSidebar">
<div class="adf-viewer__sidebar adf-viewer__sidebar__right"
id="adf-right-sidebar">
<ng-container *ngIf="sidebarRightTemplate">
<ng-container *ngTemplateOutlet="sidebarRightTemplate;context:sidebarRightTemplateContext" />
</ng-container>
<ng-content *ngIf="!sidebarRightTemplate"
select="adf-viewer-sidebar" />
</div>
</ng-container>
<ng-container *ngIf="allowLeftSidebar && showLeftSidebar">
<div class="adf-viewer__sidebar adf-viewer__sidebar__left"
id="adf-left-sidebar">
<ng-container *ngIf="sidebarLeftTemplate">
<ng-container *ngTemplateOutlet="sidebarLeftTemplate;context:sidebarLeftTemplateContext" />
</ng-container>
<ng-content *ngIf="!sidebarLeftTemplate"
select="adf-viewer-sidebar" />
</div>
</ng-container>
<adf-viewer-render (close)="onClose()"
[mimeType]="mimeType"
[fileName]="fileName"
[blobFile]="blobFile"
[readOnly]="readOnly"
(submitFile)="onSubmitFile($event)"
[allowedEditActions]="allowedEditActions"
[urlFile]="urlFile"
(isSaving)="allowNavigate = !$event"
[tracks]="tracks"
[viewerTemplateExtensions]="viewerExtensions ?? viewerTemplateExtensions"
[nodeId]="nodeId"
[customError]="customError" />
</div>
</div>
</div>

View File

@@ -0,0 +1,164 @@
/* stylelint-disable scss/at-extend-no-missing-placeholder */
@use 'mat-selectors' as ms;
.adf-full-screen {
width: 100%;
height: 100%;
background-color: var(--adf-theme-background-card-color);
}
.adf-viewer {
position: absolute;
width: 100%;
height: 100%;
#{ms.$mat-toolbar} {
color: var(--adf-theme-foreground-text-color-054);
.adf-toolbar-title {
width: auto;
}
}
&-main {
width: 0;
}
&__mimeicon {
vertical-align: middle;
height: 18px;
width: 18px;
margin-left: 4px;
margin-right: 4px;
}
&-toolbar {
#{ms.$mat-toolbar} {
background-color: var(--adf-theme-background-card-color-087);
}
}
&__file-title {
text-align: center;
flex: 1 1 auto;
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: row;
align-items: center;
@media screen and (width <= 1450px) {
left: 30%;
}
}
&__display-name {
font-size: var(--theme-subheading-2-font-size);
opacity: 0.87;
line-height: 1.5;
letter-spacing: -0.4px;
font-weight: normal;
font-style: normal;
font-stretch: normal;
vertical-align: middle;
color: var(--adf-theme-foreground-text-color);
&-without-extension {
display: inline-block;
text-overflow: ellipsis;
overflow: hidden;
max-width: 400px;
white-space: nowrap;
vertical-align: bottom;
}
}
&-container {
.adf-viewer-layout-content {
@extend .adf-full-screen;
position: relative;
overflow: hidden;
z-index: 1;
background-color: var(--theme-background-color);
display: flex;
flex-direction: row;
/* stylelint-disable-next-line declaration-block-no-redundant-longhand-properties */
flex-wrap: wrap;
flex: 1;
& > div {
display: flex;
flex-flow: row wrap;
margin: 0 auto;
align-items: stretch;
height: 100%;
}
}
.adf-viewer-layout {
@extend .adf-full-screen;
display: flex;
flex-direction: row;
overflow: hidden auto;
position: relative;
}
.adf-viewer-content {
@extend .adf-full-screen;
flex: 1;
flex-direction: column;
display: flex;
& > div {
height: 0; // Firefox
}
}
}
&-overlay-container {
.adf-viewer-content {
position: fixed;
top: 0;
left: 0;
z-index: 1000;
}
}
&-inline-container {
@extend .adf-full-screen;
}
&-sidebars {
display: flex;
flex: 1 1 auto;
.adf-viewer__sidebar {
width: 350px;
display: block;
padding: 0;
background-color: var(--theme-background-color);
box-shadow: 0 2px 4px 0 var(--adf-theme-foreground-text-color-027);
overflow: auto;
&__right {
border-left: 1px solid var(--adf-theme-foreground-text-color-007);
order: 4;
}
&__left {
border-right: 1px solid var(--adf-theme-foreground-text-color-007);
order: 1;
}
}
adf-viewer-render {
order: 1;
flex: 1 1 auto;
display: flex;
}
}
}

View File

@@ -0,0 +1,673 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component } from '@angular/core';
import { ComponentFixture, discardPeriodicTasks, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { of } from 'rxjs';
import { AppConfigService } from '../../app-config';
import { EventMock } from '../../mock';
import { CoreTestingModule, UnitTestingUtils } from '../../testing';
import { DownloadPromptActions } from '../models/download-prompt.actions';
import { CloseButtonPosition } from '../models/viewer.model';
import { ViewUtilService } from '../services/view-util.service';
import { DownloadPromptDialogComponent } from './download-prompt-dialog/download-prompt-dialog.component';
import { ViewerWithCustomMoreActionsComponent } from './mock/adf-viewer-container-more-actions.component.mock';
import { ViewerWithCustomOpenWithComponent } from './mock/adf-viewer-container-open-with.component.mock';
import { ViewerWithCustomSidebarComponent } from './mock/adf-viewer-container-sidebar.component.mock';
import { ViewerWithCustomToolbarActionsComponent } from './mock/adf-viewer-container-toolbar-actions.component.mock';
import { ViewerWithCustomToolbarComponent } from './mock/adf-viewer-container-toolbar.component.mock';
import { ViewerComponent } from './viewer.component';
import { ThumbnailService } from '../../common/services/thumbnail.service';
@Component({
selector: 'adf-dialog-dummy',
template: ``
})
class DummyDialogComponent {}
describe('ViewerComponent', () => {
let component: ViewerComponent<any>;
let fixture: ComponentFixture<ViewerComponent<any>>;
let dialog: MatDialog;
let viewUtilService: ViewUtilService;
let appConfigService: AppConfigService;
let thumbnailService: ThumbnailService;
let testingUtils: UnitTestingUtils;
const getFileName = (): string => testingUtils.getByCSS('#adf-viewer-display-name').nativeElement.textContent;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
CoreTestingModule,
MatButtonModule,
MatIconModule,
ViewerWithCustomToolbarComponent,
ViewerWithCustomSidebarComponent,
ViewerWithCustomOpenWithComponent,
ViewerWithCustomMoreActionsComponent,
ViewerWithCustomToolbarActionsComponent
],
providers: [MatDialog, { provide: DownloadPromptDialogComponent, useClass: DummyDialogComponent }]
});
fixture = TestBed.createComponent(ViewerComponent);
testingUtils = new UnitTestingUtils(fixture.debugElement);
component = fixture.componentInstance;
dialog = TestBed.inject(MatDialog);
viewUtilService = TestBed.inject(ViewUtilService);
appConfigService = TestBed.inject(AppConfigService);
thumbnailService = TestBed.inject(ThumbnailService);
component.fileName = 'test-file.pdf';
appConfigService.config = {
...appConfigService.config,
viewer: {
enableDownloadPrompt: false,
enableDownloadPromptReminder: false,
downloadPromptDelay: 3,
downloadPromptReminderDelay: 2
}
};
});
afterEach(() => {
fixture.destroy();
});
describe('Mime Type Test', () => {
it('should mimeType change when blobFile changes', () => {
const mockSimpleChanges: any = { blobFile: { currentValue: { type: 'image/png' } } };
component.ngOnChanges(mockSimpleChanges);
expect(component.mimeType).toBe('image/png');
});
it('should set mimeTypeIconUrl when mimeType changes and no nodeMimeType is provided', () => {
spyOn(thumbnailService, 'getMimeTypeIcon').and.returnValue('image/png');
const mockSimpleChanges: any = { mimeType: { currentValue: 'image/png' }, nodeMimeType: undefined };
component.ngOnChanges(mockSimpleChanges);
expect(thumbnailService.getMimeTypeIcon).toHaveBeenCalledWith('image/png');
expect(component.mimeTypeIconUrl).toBe('image/png');
});
it('should set mimeTypeIconUrl when nodeMimeType changes', () => {
spyOn(thumbnailService, 'getMimeTypeIcon').and.returnValue('application/pdf');
const mockSimpleChanges: any = { mimeType: { currentValue: 'image/png' }, nodeMimeType: { currentValue: 'application/pdf' } };
component.ngOnChanges(mockSimpleChanges);
fixture.detectChanges();
expect(thumbnailService.getMimeTypeIcon).toHaveBeenCalledWith('application/pdf');
expect(component.mimeTypeIconUrl).toBe('application/pdf');
});
it('should reset urlFile and blobFile on onNavigateBeforeClick', () => {
component.urlFile = 'some-url';
component.blobFile = new Blob(['content'], { type: 'text/plain' });
component.onNavigateBeforeClick(new MouseEvent('click'));
expect(component.urlFile).toBe('');
expect(component.blobFile).toBeNull();
});
it('should reset urlFile and blobFile on onNavigateNextClick', () => {
component.urlFile = 'some-url';
component.blobFile = new Blob(['content'], { type: 'text/plain' });
component.onNavigateNextClick(new MouseEvent('click'));
expect(component.urlFile).toBe('');
expect(component.blobFile).toBeNull();
});
});
describe('File Name Test', () => {
const getFileNameWithoutExtension = (): string =>
testingUtils.getByCSS('.adf-viewer__display-name-without-extension').nativeElement.textContent;
const getExtension = (): string => testingUtils.getByCSS('.adf-viewer__display-name-extension').nativeElement.textContent;
it('should fileName be set by urlFile input if the fileName is not provided as Input', () => {
component.fileName = '';
spyOn(viewUtilService, 'getFilenameFromUrl').and.returnValue('fakeFileName.jpeg');
const mockSimpleChanges: any = { urlFile: { currentValue: 'https://fakefile.url/fakeFileName.jpeg' } };
component.ngOnChanges(mockSimpleChanges);
fixture.detectChanges();
expect(getFileName()).toEqual('fakeFileName.jpeg');
expect(getFileNameWithoutExtension()).toBe('fakeFileName.');
expect(getExtension()).toBe('jpeg');
});
it('should set fileName providing fileName input', () => {
component.fileName = 'testFileName.jpg';
spyOn(viewUtilService, 'getFilenameFromUrl').and.returnValue('fakeFileName.jpeg');
const mockSimpleChanges: any = { urlFile: { currentValue: 'https://fakefile.url/fakeFileName.jpeg' } };
component.ngOnChanges(mockSimpleChanges);
fixture.detectChanges();
fixture.detectChanges();
expect(getFileName()).toEqual('testFileName.jpg');
expect(getFileNameWithoutExtension()).toBe('testFileName.');
expect(getExtension()).toBe('jpg');
});
});
describe('Viewer Example Component Rendering', () => {
it('should use custom toolbar', (done) => {
const customFixture = TestBed.createComponent(ViewerWithCustomToolbarComponent);
testingUtils.setDebugElement(customFixture.debugElement);
customFixture.detectChanges();
fixture.whenStable().then(() => {
expect(testingUtils.getByCSS('.custom-toolbar-element')).toBeDefined();
done();
});
});
it('should use custom toolbar actions', (done) => {
const customFixture = TestBed.createComponent(ViewerWithCustomToolbarActionsComponent);
testingUtils.setDebugElement(customFixture.debugElement);
customFixture.detectChanges();
fixture.whenStable().then(() => {
expect(testingUtils.getByCSS('#custom-button')).toBeDefined();
done();
});
});
it('should use custom info drawer', (done) => {
const customFixture = TestBed.createComponent(ViewerWithCustomSidebarComponent);
testingUtils.setDebugElement(customFixture.debugElement);
customFixture.detectChanges();
fixture.whenStable().then(() => {
expect(testingUtils.getByCSS('.custom-info-drawer-element')).toBeDefined();
done();
});
});
it('should use custom open with menu', (done) => {
const customFixture = TestBed.createComponent(ViewerWithCustomOpenWithComponent);
testingUtils.setDebugElement(customFixture.debugElement);
customFixture.detectChanges();
fixture.whenStable().then(() => {
expect(testingUtils.getByCSS('.adf-viewer-container-open-with')).toBeDefined();
done();
});
});
it('should use custom more actions menu', (done) => {
const customFixture = TestBed.createComponent(ViewerWithCustomMoreActionsComponent);
testingUtils.setDebugElement(customFixture.debugElement);
customFixture.detectChanges();
fixture.whenStable().then(() => {
expect(testingUtils.getByCSS('.adf-viewer-container-more-actions')).toBeDefined();
done();
});
});
});
describe('Toolbar', () => {
it('should show only next file button', async () => {
component.allowNavigate = true;
component.canNavigateBefore = false;
component.canNavigateNext = true;
fixture.detectChanges();
await fixture.whenStable();
expect(testingUtils.getByDataAutomationId('adf-toolbar-next-file')).not.toBeNull();
expect(testingUtils.getByDataAutomationId('adf-toolbar-pref-file')).toBeNull();
});
it('should provide tooltip for next file button', async () => {
component.allowNavigate = true;
component.canNavigateBefore = false;
component.canNavigateNext = true;
fixture.detectChanges();
await fixture.whenStable();
expect(testingUtils.getByDataAutomationId('adf-toolbar-next-file').nativeElement.title).toBe('ADF_VIEWER.ACTIONS.NEXT_FILE');
});
it('should show only previous file button', async () => {
component.allowNavigate = true;
component.canNavigateBefore = true;
component.canNavigateNext = false;
fixture.detectChanges();
await fixture.whenStable();
expect(testingUtils.getByDataAutomationId('adf-toolbar-next-file')).toBeNull();
expect(testingUtils.getByDataAutomationId('adf-toolbar-pref-file')).not.toBeNull();
});
it('should provide tooltip for the previous file button', async () => {
component.allowNavigate = true;
component.canNavigateBefore = true;
component.canNavigateNext = false;
fixture.detectChanges();
await fixture.whenStable();
expect(testingUtils.getByDataAutomationId('adf-toolbar-pref-file').nativeElement.title).toBe('ADF_VIEWER.ACTIONS.PREV_FILE');
});
it('should show both file navigation buttons', async () => {
component.allowNavigate = true;
component.canNavigateBefore = true;
component.canNavigateNext = true;
fixture.detectChanges();
await fixture.whenStable();
expect(testingUtils.getByDataAutomationId('adf-toolbar-next-file')).not.toBeNull();
expect(testingUtils.getByDataAutomationId('adf-toolbar-pref-file')).not.toBeNull();
});
it('should not show navigation buttons', async () => {
component.allowNavigate = false;
fixture.detectChanges();
await fixture.whenStable();
expect(testingUtils.getByDataAutomationId('adf-toolbar-next-file')).toBeNull();
expect(testingUtils.getByDataAutomationId('adf-toolbar-pref-file')).toBeNull();
});
it('should not show navigation buttons if file is saving', async () => {
component.allowNavigate = true;
fixture.detectChanges();
const viewerRender = testingUtils.getByCSS('adf-viewer-render');
viewerRender.triggerEventHandler('isSaving', true);
expect(component.allowNavigate).toBeFalsy();
viewerRender.triggerEventHandler('isSaving', false);
expect(component.allowNavigate).toBeTruthy();
});
it('should now show navigation buttons even if navigation enabled', async () => {
component.allowNavigate = true;
component.canNavigateBefore = false;
component.canNavigateNext = false;
fixture.detectChanges();
await fixture.whenStable();
expect(testingUtils.getByDataAutomationId('adf-toolbar-next-file')).toBeNull();
expect(testingUtils.getByDataAutomationId('adf-toolbar-pref-file')).toBeNull();
});
it('should render fullscreen button', () => {
expect(testingUtils.getByDataAutomationId('adf-toolbar-fullscreen')).toBeDefined();
});
it('should render close viewer button if it is not a shared link', (done) => {
component.closeButtonPosition = CloseButtonPosition.Left;
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(testingUtils.getByDataAutomationId('adf-toolbar-left-back')).not.toBeNull();
done();
});
});
});
describe('Base component', () => {
beforeEach(() => {
component.mimeType = 'application/pdf';
fixture.detectChanges();
});
describe('SideBar Test', () => {
it('should NOT display sidebar if is not allowed', (done) => {
component.showRightSidebar = true;
component.allowRightSidebar = false;
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(testingUtils.getByCSS('#adf-right-sidebar')).toBeNull();
done();
});
});
it('should display sidebar on the right side', (done) => {
component.allowRightSidebar = true;
component.showRightSidebar = true;
fixture.detectChanges();
fixture.whenStable().then(() => {
const sidebar = testingUtils.getByCSS('#adf-right-sidebar').nativeElement;
expect(getComputedStyle(sidebar).order).toEqual('4');
done();
});
});
it('should NOT display left sidebar if is not allowed', (done) => {
component.showLeftSidebar = true;
component.allowLeftSidebar = false;
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(testingUtils.getByCSS('#adf-left-sidebar')).toBeNull();
done();
});
});
it('should display sidebar on the left side', (done) => {
component.allowLeftSidebar = true;
component.showLeftSidebar = true;
fixture.detectChanges();
fixture.whenStable().then(() => {
const sidebar = testingUtils.getByCSS('#adf-left-sidebar').nativeElement;
expect(getComputedStyle(sidebar).order).toEqual('1');
done();
});
});
});
describe('Info Button', () => {
const infoButton = () => testingUtils.getByDataAutomationId('adf-toolbar-sidebar');
it('should NOT display info button on the right side', () => {
component.allowRightSidebar = true;
component.hideInfoButton = true;
fixture.detectChanges();
expect(infoButton()).toBeNull();
});
it('should display info button on the right side', () => {
component.allowRightSidebar = true;
component.hideInfoButton = false;
fixture.detectChanges();
expect(infoButton()).not.toBeNull();
});
});
describe('View', () => {
describe('Overlay mode true', () => {
beforeEach(() => {
component.overlayMode = true;
component.fileName = 'fake-test-file.pdf';
fixture.detectChanges();
});
it('should header be present if is overlay mode', () => {
expect(testingUtils.getByCSS('.adf-viewer-toolbar')).not.toBeNull();
});
it('should file name be present if is overlay mode ', async () => {
const mockSimpleChanges: any = { blobFile: { currentValue: { type: 'image/png' } } };
component.ngOnChanges(mockSimpleChanges);
fixture.detectChanges();
await fixture.whenStable();
expect(getFileName()).toEqual('fake-test-file.pdf');
});
it('should Close button be present if overlay mode', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(testingUtils.getByCSS('.adf-viewer-close-button')).not.toBeNull();
});
it('should Click on close button hide the viewer', async () => {
testingUtils.clickByCSS('.adf-viewer-close-button');
fixture.detectChanges();
await fixture.whenStable();
expect(testingUtils.getByCSS('.adf-viewer-content')).toBeNull();
});
it('should Esc button hide the viewer', async () => {
EventMock.keyDown(27);
fixture.detectChanges();
await fixture.whenStable();
expect(testingUtils.getByCSS('.adf-viewer-content')).toBeNull();
});
it('should not close the viewer on Escape event if dialog was opened', (done) => {
const event = new KeyboardEvent('keydown', {
bubbles: true,
keyCode: 27
} as KeyboardEventInit);
const dialogRef = dialog.open(DummyDialogComponent);
dialogRef.afterClosed().subscribe(() => {
EventMock.keyDown(27);
fixture.detectChanges();
expect(testingUtils.getByCSS('.adf-viewer-content')).toBeNull();
done();
});
fixture.detectChanges();
document.body.dispatchEvent(event);
fixture.detectChanges();
expect(testingUtils.getByCSS('.adf-viewer-content')).not.toBeNull();
});
});
describe('Overlay mode false', () => {
beforeEach(() => {
component.overlayMode = false;
fixture.detectChanges();
});
it('should Esc button not hide the viewer if is not overlay mode', (done) => {
EventMock.keyDown(27);
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(testingUtils.getByCSS('.adf-viewer-content')).not.toBeNull();
done();
});
});
});
});
describe('Attribute', () => {
it('should showViewer default value be true', () => {
expect(component.showViewer).toBe(true);
});
it('should viewer be hide if showViewer value is false', () => {
component.showViewer = false;
fixture.detectChanges();
expect(testingUtils.getByCSS('.adf-viewer-content')).toBeNull();
});
});
describe('Close Button', () => {
const getRightCloseButton = () => testingUtils.getByDataAutomationId('adf-toolbar-right-back');
const getLeftCloseButton = () => testingUtils.getByDataAutomationId('adf-toolbar-left-back');
it('should show close button on left side when closeButtonPosition is left and allowGoBack is true', () => {
component.allowGoBack = true;
component.closeButtonPosition = CloseButtonPosition.Left;
fixture.detectChanges();
expect(getLeftCloseButton()).not.toBeNull();
expect(getRightCloseButton()).toBeNull();
});
it('should show close button on right side when closeButtonPosition is right and allowGoBack is true', () => {
component.allowGoBack = true;
component.closeButtonPosition = CloseButtonPosition.Right;
fixture.detectChanges();
expect(getRightCloseButton()).not.toBeNull();
expect(getLeftCloseButton()).toBeNull();
});
it('should hide close button allowGoBack is false', () => {
component.allowGoBack = false;
fixture.detectChanges();
expect(getRightCloseButton()).toBeNull();
expect(getLeftCloseButton()).toBeNull();
});
});
describe('Viewer component - Full Screen Mode - Mocking fixture element', () => {
beforeEach(() => {
fixture = TestBed.createComponent(ViewerComponent);
component = fixture.componentInstance;
component.showToolbar = true;
component.mimeType = 'application/pdf';
fixture.detectChanges();
});
it('should request only if enabled', () => {
const domElement = jasmine.createSpyObj('el', ['requestFullscreen']);
spyOn(fixture.nativeElement, 'querySelector').and.returnValue(domElement);
component.allowFullScreen = false;
component.enterFullScreen();
expect(domElement.requestFullscreen).not.toHaveBeenCalled();
});
it('should use standard mode', () => {
const domElement = jasmine.createSpyObj('el', ['requestFullscreen']);
spyOn(fixture.nativeElement, 'querySelector').and.returnValue(domElement);
component.enterFullScreen();
expect(domElement.requestFullscreen).toHaveBeenCalled();
});
it('should use webkit prefix', () => {
const domElement = jasmine.createSpyObj('el', ['webkitRequestFullscreen']);
spyOn(fixture.nativeElement, 'querySelector').and.returnValue(domElement);
component.enterFullScreen();
expect(domElement.webkitRequestFullscreen).toHaveBeenCalled();
});
it('should use moz prefix', () => {
const domElement = jasmine.createSpyObj('el', ['mozRequestFullScreen']);
spyOn(fixture.nativeElement, 'querySelector').and.returnValue(domElement);
component.enterFullScreen();
expect(domElement.mozRequestFullScreen).toHaveBeenCalled();
});
it('should use ms prefix', () => {
const domElement = jasmine.createSpyObj('el', ['msRequestFullscreen']);
spyOn(fixture.nativeElement, 'querySelector').and.returnValue(domElement);
component.enterFullScreen();
expect(domElement.msRequestFullscreen).toHaveBeenCalled();
});
});
});
describe('Download Prompt Dialog', () => {
let dialogOpenSpy: jasmine.Spy;
beforeEach(() => {
appConfigService.config = {
...appConfigService.config,
viewer: {
enableDownloadPrompt: true,
enableDownloadPromptReminder: true,
downloadPromptDelay: 3,
downloadPromptReminderDelay: 2
}
};
dialogOpenSpy = spyOn(dialog, 'open').and.returnValue({ afterClosed: () => of(null) } as any);
component.urlFile = undefined;
component.clearDownloadPromptTimeouts();
});
it('should configure initial timeout to display non responsive dialog when initialising component', () => {
fixture.detectChanges();
expect(component.downloadPromptTimer).toBeDefined();
});
it('should configure reminder timeout to display non responsive dialog after initial dialog', fakeAsync(() => {
dialogOpenSpy.and.returnValue({ afterClosed: () => of(DownloadPromptActions.WAIT) } as any);
fixture.detectChanges();
tick(3000);
expect(component.downloadPromptReminderTimer).toBeDefined();
dialogOpenSpy.and.returnValue({ afterClosed: () => of(null) } as any);
flush();
discardPeriodicTasks();
}));
it('should show initial non responsive dialog after initial timeout', fakeAsync(() => {
fixture.detectChanges();
tick(3000);
fixture.detectChanges();
expect(dialogOpenSpy).toHaveBeenCalled();
}));
it('should not show non responsive dialog if blobFile was provided', fakeAsync(() => {
component.blobFile = new Blob(['mock content'], { type: 'text/plain' });
fixture.detectChanges();
tick(3000);
fixture.detectChanges();
expect(dialogOpenSpy).not.toHaveBeenCalled();
}));
it('should show reminder non responsive dialog after initial dialog', fakeAsync(() => {
dialogOpenSpy.and.returnValue({ afterClosed: () => of(DownloadPromptActions.WAIT) } as any);
fixture.detectChanges();
tick(3000);
expect(dialogOpenSpy).toHaveBeenCalled();
dialogOpenSpy.and.returnValue({ afterClosed: () => of(null) } as any);
tick(2000);
expect(dialogOpenSpy).toHaveBeenCalledTimes(2);
flush();
discardPeriodicTasks();
}));
it('should emit downloadFileEvent when DownloadPromptDialog return DownloadPromptActions.DOWNLOAD on close', fakeAsync(() => {
dialogOpenSpy.and.returnValue({ afterClosed: () => of(DownloadPromptActions.DOWNLOAD) } as any);
spyOn(component.downloadFile, 'emit');
fixture.detectChanges();
tick(3000);
fixture.detectChanges();
expect(component.downloadFile.emit).toHaveBeenCalled();
}));
});
});

View File

@@ -0,0 +1,536 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { A11yModule } from '@angular/cdk/a11y';
import { NgIf, NgTemplateOutlet } from '@angular/common';
import {
Component,
ContentChild,
DestroyRef,
ElementRef,
EventEmitter,
HostListener,
inject,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
TemplateRef,
ViewEncapsulation
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { TranslateModule } from '@ngx-translate/core';
import { fromEvent } from 'rxjs';
import { filter, first, skipWhile } from 'rxjs/operators';
import { DownloadPromptActions } from '../models/download-prompt.actions';
import { CloseButtonPosition, Track } from '../models/viewer.model';
import { ViewUtilService } from '../services/view-util.service';
import { DownloadPromptDialogComponent } from './download-prompt-dialog/download-prompt-dialog.component';
import { ViewerMoreActionsComponent } from './viewer-more-actions.component';
import { ViewerOpenWithComponent } from './viewer-open-with.component';
import { ViewerRenderComponent } from './viewer-render/viewer-render.component';
import { ViewerSidebarComponent } from './viewer-sidebar.component';
import { ViewerToolbarComponent } from './viewer-toolbar.component';
import { ViewerToolbarActionsComponent } from './viewer-toolbar-actions.component';
import { ViewerToolbarCustomActionsComponent } from './viewer-toolbar-custom-actions.component';
import {
ThumbnailService,
IconComponent,
ToolbarComponent,
ToolbarDividerComponent,
ToolbarTitleComponent,
AppConfigService
} from '@alfresco/adf-core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
const DEFAULT_NON_PREVIEW_CONFIG = {
enableDownloadPrompt: false,
enableDownloadPromptReminder: false,
downloadPromptDelay: 50,
downloadPromptReminderDelay: 30
};
@Component({
selector: 'adf-viewer',
standalone: true,
templateUrl: './viewer.component.html',
styleUrls: ['./viewer.component.scss'],
host: { class: 'adf-viewer' },
encapsulation: ViewEncapsulation.None,
imports: [
NgIf,
A11yModule,
ToolbarComponent,
ToolbarTitleComponent,
MatButtonModule,
TranslateModule,
MatIconModule,
MatMenuModule,
ToolbarDividerComponent,
ViewerRenderComponent,
NgTemplateOutlet,
ViewerToolbarComponent,
ViewerSidebarComponent,
ViewerToolbarActionsComponent,
ViewerToolbarCustomActionsComponent,
IconComponent
],
providers: [ViewUtilService]
})
export class ViewerComponent<T> implements OnDestroy, OnInit, OnChanges {
private thumbnailService = inject(ThumbnailService);
@ContentChild(ViewerToolbarComponent)
toolbar: ViewerToolbarComponent;
@ContentChild(ViewerSidebarComponent)
sidebar: ViewerSidebarComponent;
@ContentChild(ViewerOpenWithComponent)
mnuOpenWith: ViewerOpenWithComponent;
@ContentChild(ViewerMoreActionsComponent)
mnuMoreActions: ViewerMoreActionsComponent;
@ContentChild('viewerExtensions', { static: false })
viewerTemplateExtensions: TemplateRef<any>;
get CloseButtonPosition() {
return CloseButtonPosition;
}
/**
* If you want to load an external file that does not come from ACS you
* can use this URL to specify where to load the file from.
*/
@Input()
urlFile = '';
/** Loads a Blob File */
@Input()
blobFile: Blob;
/** Hide or show the viewer */
@Input()
showViewer = true;
/** Allows `back` navigation */
@Input()
allowGoBack = true;
/** Toggles the 'Full Screen' feature. */
@Input()
allowFullScreen = true;
/** Hide or show the toolbar */
@Input()
showToolbar = true;
/**
* If `true` then show the Viewer as a full page over the current content.
* Otherwise, fit inside the parent div.
*/
@Input()
overlayMode = false;
/**
* Toggles before/next navigation. You can use the arrow buttons to navigate
* between documents in the collection.
*/
@Input()
allowNavigate = false;
/** Toggles the "before" ("<") button. Requires `allowNavigate` to be enabled. */
@Input()
canNavigateBefore = true;
/** Toggles the next (">") button. Requires `allowNavigate` to be enabled. */
@Input()
canNavigateNext = true;
/** Allow the left the sidebar. */
@Input()
allowLeftSidebar = false;
/** Allow the right sidebar. */
@Input()
allowRightSidebar = false;
/** Toggles right sidebar visibility. Requires `allowRightSidebar` to be set to `true`. */
@Input()
showRightSidebar = false;
/** Toggles left sidebar visibility. Requires `allowLeftSidebar` to be set to `true`. */
@Input()
showLeftSidebar = false;
/** The template for the right sidebar. The template context contains the loaded node data. */
@Input()
sidebarRightTemplate: TemplateRef<any> = null;
/** The template for the left sidebar. The template context contains the loaded node data. */
@Input()
sidebarLeftTemplate: TemplateRef<any> = null;
/** Enable when where is possible the editing functionalities */
@Input()
readOnly = true;
/**
* Controls which actions are enabled in the viewer.
* Example:
* { rotate: true, crop: false } will enable rotation but disable cropping.
*/
@Input()
allowedEditActions: { [key: string]: boolean } = {
rotate: true,
crop: true
};
/** media subtitles for the media player*/
@Input()
tracks: Track[] = [];
/** Overload mimeType*/
@Input()
mimeType: string;
/**
* Context object available for binding by the local sidebarRightTemplate with let declarations.
*/
@Input()
sidebarRightTemplateContext: T = null;
/**
* Context object available for binding by the local sidebarLeftTemplate with let declarations.
*/
@Input()
sidebarLeftTemplateContext: T = null;
/**
* Change the close button position Right/Left.
*/
@Input()
closeButtonPosition = CloseButtonPosition.Left;
/** Toggles the 'Info Button' */
@Input()
hideInfoButton = false;
/** Template containing ViewerExtensionDirective instances providing different viewer extensions based on supported file extension. */
@Input()
viewerExtensions: TemplateRef<any>;
/** Identifier of a node that is opened by the viewer. */
@Input()
nodeId: string = null;
/** Original node mime type, should be provided when renditiona mime type is different. */
@Input()
nodeMimeType: string = undefined;
/** Custom error message to be displayed in the viewer. */
@Input()
customError: string = undefined;
/**
* Enable dialog box to allow user to download the previewed file, in case the preview is not responding for a set period of time.
*/
enableDownloadPrompt: boolean = false;
/**
* Enable reminder dialogs to prompt user to download the file, in case the preview is not responding for a set period of time.
*/
enableDownloadPromptReminder: boolean = false;
/**
* Initial time in seconds to wait before giving the first prompt to user to download the file
*/
downloadPromptDelay: number = 50;
/**
* Time in seconds to wait before giving the second and consequent reminders to the user to download the file.
*/
downloadPromptReminderDelay: number = 15;
/**
* Emitted when user clicks on download button on download prompt dialog.
*/
@Output()
downloadFile = new EventEmitter<void>();
/** Emitted when user clicks 'Navigate Before' ("<") button. */
@Output()
navigateBefore = new EventEmitter<MouseEvent | KeyboardEvent>();
/** Emitted when user clicks 'Navigate Next' (">") button. */
@Output()
navigateNext = new EventEmitter<MouseEvent | KeyboardEvent>();
/** Emitted when the viewer close */
@Output()
showViewerChange = new EventEmitter<boolean>();
/** Emitted when the img is submitted in the img viewer. */
@Output()
submitFile = new EventEmitter<Blob>();
private closeViewer = true;
private keyDown$ = fromEvent<KeyboardEvent>(document, 'keydown');
private isDialogVisible = false;
private _fileName: string;
private _fileNameWithoutExtension: string;
private _fileExtension: string;
public downloadPromptTimer: number;
public downloadPromptReminderTimer: number;
public mimeTypeIconUrl: string;
private readonly destroyRef = inject(DestroyRef);
/** Override Content filename. */
@Input()
set fileName(fileName: string) {
this._fileName = fileName;
this._fileExtension = this.viewUtilsService.getFileExtension(this.fileName);
this._fileNameWithoutExtension = this.fileName?.replace(new RegExp(`${this.fileExtension}$`), '') || '';
}
get fileName(): string {
return this._fileName;
}
get fileExtension(): string {
return this._fileExtension;
}
get fileNameWithoutExtension(): string {
return this._fileNameWithoutExtension;
}
constructor(
private el: ElementRef,
public dialog: MatDialog,
private viewUtilsService: ViewUtilService,
private appConfigService: AppConfigService
) {}
ngOnChanges(changes: SimpleChanges) {
const { blobFile, urlFile, mimeType, nodeMimeType } = changes;
if (blobFile?.currentValue) {
this.mimeType = blobFile.currentValue.type;
this.mimeTypeIconUrl = this.thumbnailService.getMimeTypeIcon(blobFile.currentValue.type);
}
if (urlFile?.currentValue) {
this.fileName ||= this.viewUtilsService.getFilenameFromUrl(urlFile.currentValue);
}
if (mimeType?.currentValue && !nodeMimeType?.currentValue) {
this.mimeTypeIconUrl = this.thumbnailService.getMimeTypeIcon(mimeType.currentValue);
}
if (nodeMimeType?.currentValue) {
this.mimeTypeIconUrl = this.thumbnailService.getMimeTypeIcon(nodeMimeType.currentValue);
}
}
ngOnInit(): void {
this.closeOverlayManager();
this.configureAndInitDownloadPrompt();
}
private closeOverlayManager() {
this.dialog.afterOpened
.pipe(
skipWhile(() => !this.overlayMode),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => (this.closeViewer = false));
this.dialog.afterAllClosed
.pipe(
skipWhile(() => !this.overlayMode),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => (this.closeViewer = true));
this.keyDown$
.pipe(
skipWhile(() => !this.overlayMode),
filter((e: KeyboardEvent) => e.keyCode === 27),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((event: KeyboardEvent) => {
event.preventDefault();
if (this.closeViewer) {
this.onClose();
}
});
}
onNavigateBeforeClick(event: MouseEvent | KeyboardEvent) {
this.resetLoadingSpinner();
this.navigateBefore.next(event);
}
onNavigateNextClick(event: MouseEvent | KeyboardEvent) {
this.resetLoadingSpinner();
this.navigateNext.next(event);
}
/**
* close the viewer
*/
onClose() {
this.showViewer = false;
this.showViewerChange.emit(this.showViewer);
}
toggleRightSidebar() {
this.showRightSidebar = !this.showRightSidebar;
}
toggleLeftSidebar() {
this.showLeftSidebar = !this.showLeftSidebar;
}
@HostListener('document:keyup', ['$event'])
handleKeyboardEvent(event: KeyboardEvent) {
if (event?.defaultPrevented) {
return;
}
if (event.key === 'ArrowLeft' && this.canNavigateBefore) {
event.preventDefault();
this.onNavigateBeforeClick(event);
}
if (event.key === 'ArrowRight' && this.canNavigateNext) {
event.preventDefault();
this.onNavigateNextClick(event);
}
if (event.code === 'KeyF' && event.ctrlKey) {
event.preventDefault();
this.enterFullScreen();
}
}
/**
* Triggers full screen mode with a main content area displayed.
*/
enterFullScreen(): void {
if (this.allowFullScreen) {
const container = this.el.nativeElement.querySelector('.adf-viewer__fullscreen-container');
if (container) {
if (container.requestFullscreen) {
container.requestFullscreen();
} else if (container.webkitRequestFullscreen) {
container.webkitRequestFullscreen();
} else if (container.mozRequestFullScreen) {
container.mozRequestFullScreen();
} else if (container.msRequestFullscreen) {
container.msRequestFullscreen();
}
}
}
}
onSubmitFile(newImageBlob: Blob) {
this.submitFile.emit(newImageBlob);
}
ngOnDestroy() {
this.clearDownloadPromptTimeouts();
}
private configureAndInitDownloadPrompt() {
this.configureDownloadPromptProperties();
if (this.enableDownloadPrompt) {
this.initDownloadPrompt();
}
}
private configureDownloadPromptProperties() {
const nonResponsivePreviewConfig = this.appConfigService.get('viewer', DEFAULT_NON_PREVIEW_CONFIG);
this.enableDownloadPrompt = nonResponsivePreviewConfig.enableDownloadPrompt;
this.enableDownloadPromptReminder = nonResponsivePreviewConfig.enableDownloadPromptReminder;
this.downloadPromptDelay = nonResponsivePreviewConfig.downloadPromptDelay;
this.downloadPromptReminderDelay = nonResponsivePreviewConfig.downloadPromptReminderDelay;
}
private initDownloadPrompt() {
this.downloadPromptTimer = window.setTimeout(() => {
this.showOrClearDownloadPrompt();
}, this.downloadPromptDelay * 1000);
}
private showOrClearDownloadPrompt() {
if (!this.urlFile && !this.blobFile) {
this.showDownloadPrompt();
} else {
this.clearDownloadPromptTimeouts();
}
}
public clearDownloadPromptTimeouts() {
if (this.downloadPromptTimer) {
clearTimeout(this.downloadPromptTimer);
}
if (this.downloadPromptReminderTimer) {
clearTimeout(this.downloadPromptReminderTimer);
}
}
private showDownloadPrompt() {
if (!this.isDialogVisible) {
this.isDialogVisible = true;
this.dialog
.open(DownloadPromptDialogComponent, { disableClose: true })
.afterClosed()
.pipe(first())
.subscribe((result: DownloadPromptActions) => {
this.isDialogVisible = false;
if (result === DownloadPromptActions.DOWNLOAD) {
this.downloadFile.emit();
this.onClose();
} else if (result === DownloadPromptActions.WAIT) {
if (this.enableDownloadPromptReminder) {
this.clearDownloadPromptTimeouts();
this.downloadPromptReminderTimer = window.setTimeout(() => {
this.showOrClearDownloadPrompt();
}, this.downloadPromptReminderDelay * 1000);
}
}
});
}
}
private resetLoadingSpinner() {
this.urlFile = '';
this.blobFile = null;
}
}