418 lines
15 KiB
TypeScript
418 lines
15 KiB
TypeScript
import { DestroyRef, inject, Injectable, signal, Signal } from '@angular/core';
|
|
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
|
|
import { ActivatedRoute } from '@angular/router';
|
|
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
|
|
import { environment } from '@environments/environment';
|
|
import { ErrorService, getConfig, LanguageService } from '@iqser/common-ui';
|
|
import { shareDistinctLast, UI_ROOT_PATH_FN } from '@iqser/common-ui/lib/utils';
|
|
import { TranslateService } from '@ngx-translate/core';
|
|
import WebViewer, { Core, WebViewerInstance, WebViewerOptions } from '@pdftron/webviewer';
|
|
import { AppConfig, File, IHeaderElement } from '@red/domain';
|
|
import { LicenseService } from '@services/license.service';
|
|
import { UserPreferenceService } from '@users/user-preference.service';
|
|
import { NGXLogger } from 'ngx-logger';
|
|
import { combineLatest, fromEvent, Observable } from 'rxjs';
|
|
import { map, startWith } from 'rxjs/operators';
|
|
import { DISABLED_HOTKEYS, DOCUMENT_LOADING_ERROR, SelectionModes, USELESS_ELEMENTS } from '../utils/constants';
|
|
import { asList } from '../utils/functions';
|
|
import { Rgb } from '../utils/types';
|
|
import { REDAnnotationManager } from './annotation-manager.service';
|
|
import Annotation = Core.Annotations.Annotation;
|
|
import TextHighlightAnnotation = Core.Annotations.TextHighlightAnnotation;
|
|
import DocumentViewer = Core.DocumentViewer;
|
|
import Quad = Core.Math.Quad;
|
|
import TextTool = Core.Tools.TextTool;
|
|
|
|
@Injectable()
|
|
export class PdfViewer {
|
|
#instance: WebViewerInstance;
|
|
readonly #convertPath = inject(UI_ROOT_PATH_FN);
|
|
readonly #licenseKey = inject(LicenseService).activeLicenseKey;
|
|
readonly #config = getConfig<AppConfig>();
|
|
readonly #isCompareMode = signal(false);
|
|
readonly #isCompareMode$ = toObservable(this.#isCompareMode);
|
|
readonly #searchButton: IHeaderElement = {
|
|
type: 'actionButton',
|
|
img: this.#convertPath('/assets/icons/general/pdftron-action-search.svg'),
|
|
title: inject(TranslateService).instant(_('pdf-viewer.text-popup.actions.search')),
|
|
onClick: () => {
|
|
setTimeout(() => {
|
|
this.#searchForSelectedText();
|
|
this.#focusSearch();
|
|
}, 250);
|
|
},
|
|
};
|
|
readonly #destroyRef = inject(DestroyRef);
|
|
readonly #totalPages = signal<number>(0);
|
|
readonly currentPage$ = inject(ActivatedRoute).queryParamMap.pipe(
|
|
map(params => Number(params.get('page') ?? '1')),
|
|
shareDistinctLast(),
|
|
);
|
|
readonly currentPage = toSignal(this.currentPage$);
|
|
documentViewer: DocumentViewer;
|
|
fileId: string;
|
|
dossierId: string;
|
|
pageChanged$: Observable<number>;
|
|
readonly isCompareMode: Signal<boolean>;
|
|
readonly totalPages: Signal<number>;
|
|
searchOptions = {
|
|
caseSensitive: false, // match case
|
|
wholeWord: false, // match whole words only
|
|
wildcard: false, // allow using '*' as a wildcard value
|
|
regex: false, // string is treated as a regular expression
|
|
searchUp: false, // search from the end of the document upwards
|
|
ambientString: true, // return ambient string as part of the result
|
|
};
|
|
selectedText = '';
|
|
|
|
readonly escKeyHandler = {
|
|
keydown: (e: KeyboardEvent) => {
|
|
e.preventDefault();
|
|
this.#clickSelectToolButton();
|
|
},
|
|
keyup: (e: KeyboardEvent) => {
|
|
e.preventDefault();
|
|
if (this.#isElementActive('searchPanel') && !this._annotationManager.resizingAnnotationId) {
|
|
this.#focusViewer();
|
|
this.deactivateSearch();
|
|
}
|
|
this.#clickSelectToolButton();
|
|
},
|
|
};
|
|
|
|
constructor(
|
|
private readonly _logger: NGXLogger,
|
|
private readonly _errorService: ErrorService,
|
|
private readonly _userPreferenceService: UserPreferenceService,
|
|
private readonly _annotationManager: REDAnnotationManager,
|
|
private readonly _languageService: LanguageService,
|
|
) {
|
|
this.totalPages = this.#totalPages.asReadonly();
|
|
this.isCompareMode = this.#isCompareMode.asReadonly();
|
|
}
|
|
|
|
get instance() {
|
|
return this.#instance;
|
|
}
|
|
|
|
get PDFNet(): typeof Core.PDFNet {
|
|
return this.#instance.Core.PDFNet;
|
|
}
|
|
|
|
get pageCount() {
|
|
try {
|
|
return this.documentViewer.getPageCount();
|
|
} catch (e) {
|
|
console.log(e);
|
|
// might throw Error: getPageCount was called before the 'documentLoaded' event
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
get #totalPages$() {
|
|
const layoutChanged$ = fromEvent(this.documentViewer, 'layoutChanged').pipe(startWith(''));
|
|
const docLoaded$ = fromEvent(this.documentViewer, 'documentLoaded');
|
|
const docChanged$ = combineLatest([docLoaded$, layoutChanged$, this.#isCompareMode$]);
|
|
|
|
return docChanged$.pipe(map(() => this.#adjustPage(this.pageCount)));
|
|
}
|
|
|
|
get #paginationOffset() {
|
|
return this.isCompareMode() ? 2 : 1;
|
|
}
|
|
|
|
get #currentInternalPage() {
|
|
return this.documentViewer.getCurrentPage();
|
|
}
|
|
|
|
get #pageChanged$() {
|
|
const page$ = fromEvent<number>(this.documentViewer, 'pageNumberUpdated');
|
|
return page$.pipe(map(page => this.#adjustPage(page)));
|
|
}
|
|
|
|
get #searchInput() {
|
|
const iframeWindow = this.#instance.UI.iframeWindow;
|
|
return iframeWindow.document.getElementById('SearchPanel__input') as HTMLInputElement;
|
|
}
|
|
|
|
activateSearch() {
|
|
this.#instance.UI.searchTextFull('', this.searchOptions);
|
|
}
|
|
|
|
deactivateSearch() {
|
|
this.instance.UI.iframeWindow.document.getElementById('SearchPanel__input').blur();
|
|
this.#updateSearchOptions();
|
|
this.#instance.UI.closeElements(['searchPanel']);
|
|
}
|
|
|
|
navigateTo(page: string | number) {
|
|
const parsedNumber = typeof page === 'string' ? parseInt(page, 10) : page;
|
|
const paginationOffset = this.#paginationOffset;
|
|
this.#navigateTo(paginationOffset === 2 ? parsedNumber * paginationOffset - 1 : parsedNumber);
|
|
}
|
|
|
|
navigatePreviousPage() {
|
|
if (this.#currentInternalPage > 1) {
|
|
this.#navigateTo(Math.max(this.#currentInternalPage - this.#paginationOffset, 1));
|
|
}
|
|
}
|
|
|
|
navigateNextPage() {
|
|
const pageCount = this.pageCount;
|
|
if (this.#currentInternalPage < pageCount) {
|
|
this.#navigateTo(Math.min(this.#currentInternalPage + this.#paginationOffset, pageCount));
|
|
}
|
|
}
|
|
|
|
async init(htmlElement: HTMLElement) {
|
|
this.#instance = await this.#getInstance(htmlElement);
|
|
|
|
if (environment.production) {
|
|
this.#instance.Core.setCustomFontURL(window.location.origin + this.#convertPath('/assets/pdftron/fonts'));
|
|
}
|
|
|
|
await this.runWithCleanup(async () => {
|
|
this.#instance.UI.setTheme(this._userPreferenceService.getTheme());
|
|
await this.#instance.UI.setLanguage(this._languageService.currentLanguage);
|
|
this._logger.info('[PDF] Initialized');
|
|
|
|
this.documentViewer = this.#instance.Core.documentViewer;
|
|
|
|
this.pageChanged$ = this.#pageChanged$.pipe(shareDistinctLast());
|
|
this.#totalPages$.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe(pages => this.#totalPages.set(pages));
|
|
this.#setSelectionMode(this.#config.SELECTION_MODE);
|
|
this.#configureElements();
|
|
this.#disableHotkeys();
|
|
this.#getSelectedText();
|
|
this.#listenForCommandF();
|
|
this.#listenForShift();
|
|
this.#clearSearchResultsWhenVisibilityChanged();
|
|
});
|
|
|
|
return this.#instance;
|
|
}
|
|
|
|
enable(dataElements: string[] | string) {
|
|
this.#instance.UI.enableElements(asList(dataElements));
|
|
}
|
|
|
|
disable(dataElements: string[] | string) {
|
|
this.#instance.UI.disableElements(asList(dataElements));
|
|
}
|
|
|
|
openCompareMode() {
|
|
this._logger.info('[PDF] Open compare mode');
|
|
this.#isCompareMode.set(true);
|
|
}
|
|
|
|
closeCompareMode() {
|
|
this._logger.info('[PDF] Close compare mode');
|
|
this.#isCompareMode.set(false);
|
|
}
|
|
|
|
runWithCleanup(action: () => Promise<void> | void) {
|
|
return this.PDFNet.runWithCleanup(action, this.#licenseKey);
|
|
}
|
|
|
|
async loadDocument(blob: Blob, file: File, actionOnError?: () => void) {
|
|
const onError = () => {
|
|
this._errorService.set(DOCUMENT_LOADING_ERROR);
|
|
this._logger.error('[PDF] Error while loading document');
|
|
actionOnError?.();
|
|
};
|
|
|
|
this.fileId = file.fileId;
|
|
this.dossierId = file.dossierId;
|
|
const filename = file?.filename.endsWith('.pdf') ? file?.filename : `${file?.filename}.pdf`;
|
|
|
|
this._logger.info('[PDF] Loading document...');
|
|
|
|
await this.runWithCleanup(async () => {
|
|
const document = await this.documentViewer.getDocument()?.getPDFDoc();
|
|
await document?.lock();
|
|
this.#instance.UI.loadDocument(blob, { documentId: file.fileId, filename: filename ?? 'document.pdf', onError });
|
|
});
|
|
}
|
|
|
|
quad(x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number) {
|
|
return new this.#instance.Core.Math.Quad(x1, y1, x2, y2, x3, y3, x4, y4);
|
|
}
|
|
|
|
translateQuad(page: number, quad: Quad): Quad {
|
|
const rotationsEnum = this.#instance.Core.PageRotation;
|
|
switch (this.documentViewer.getCompleteRotation(page)) {
|
|
case rotationsEnum.E_90:
|
|
return this.quad(quad.x2, quad.y2, quad.x3, quad.y3, quad.x4, quad.y4, quad.x1, quad.y1);
|
|
case rotationsEnum.E_180:
|
|
return this.quad(quad.x3, quad.y3, quad.x4, quad.y4, quad.x1, quad.y1, quad.x2, quad.y2);
|
|
case rotationsEnum.E_270:
|
|
return this.quad(quad.x4, quad.y4, quad.x1, quad.y1, quad.x2, quad.y2, quad.x3, quad.y3);
|
|
case rotationsEnum.E_0:
|
|
default:
|
|
return quad;
|
|
}
|
|
}
|
|
|
|
color(rgb: Rgb) {
|
|
return new this.#instance.Core.Annotations.Color(rgb.r, rgb.g, rgb.b);
|
|
}
|
|
|
|
rectangle() {
|
|
return new this.#instance.Core.Annotations.RectangleAnnotation();
|
|
}
|
|
|
|
textHighlight() {
|
|
return new this.#instance.Core.Annotations.TextHighlightAnnotation();
|
|
}
|
|
|
|
polyline() {
|
|
return new this.#instance.Core.Annotations.PolylineAnnotation();
|
|
}
|
|
|
|
isTextHighlight(annotation: Annotation): annotation is TextHighlightAnnotation {
|
|
return annotation instanceof this.#instance.Core.Annotations.TextHighlightAnnotation;
|
|
}
|
|
|
|
configureTextPopups(popups: IHeaderElement[]) {
|
|
this.#instance.UI.textPopup.update([...popups, this.#searchButton, { dataElement: 'copyTextButton' }]);
|
|
}
|
|
|
|
#listenForCommandF() {
|
|
this.#instance.UI.hotkeys.on('command+f, ctrl+f', (e: KeyboardEvent) => {
|
|
e.preventDefault();
|
|
if (this.#isElementActive('searchPanel')) {
|
|
this.#updateSearchOptions();
|
|
} else {
|
|
this.activateSearch();
|
|
}
|
|
if (this.selectedText.length) {
|
|
this.#searchForSelectedText();
|
|
}
|
|
setTimeout(() => this.#focusSearch(), 40);
|
|
});
|
|
}
|
|
|
|
#listenForShift() {
|
|
this.#instance.UI.iframeWindow.addEventListener('keydown', e => {
|
|
if (e.target === this.#searchInput) return;
|
|
if (e.key === 'Shift') {
|
|
this.#setSelectionMode(SelectionModes.RECTANGULAR);
|
|
}
|
|
});
|
|
this.#instance.UI.iframeWindow.addEventListener('keyup', e => {
|
|
if (e.target === this.#searchInput) return;
|
|
if (e.key === 'Shift') {
|
|
this.#setSelectionMode(SelectionModes.STRUCTURAL);
|
|
}
|
|
});
|
|
}
|
|
|
|
#getSearchOption(optionId: string): boolean {
|
|
const iframeWindow = this.#instance.UI.iframeWindow;
|
|
const checkbox = iframeWindow.document.getElementById(optionId) as HTMLInputElement;
|
|
return checkbox.checked;
|
|
}
|
|
|
|
#updateSearchOptions() {
|
|
const wholeWord = this.#getSearchOption('whole-word-option');
|
|
const caseSensitive = this.#getSearchOption('case-sensitive-option');
|
|
this.searchOptions = { ...this.searchOptions, wholeWord: wholeWord, caseSensitive: caseSensitive };
|
|
}
|
|
|
|
#adjustPage(page: number) {
|
|
if (this.isCompareMode()) {
|
|
if (page % 2 === 1) {
|
|
page = page + 1;
|
|
}
|
|
|
|
return page / 2;
|
|
}
|
|
|
|
return page;
|
|
}
|
|
|
|
#searchForSelectedText() {
|
|
this.#instance.UI.searchTextFull(this.selectedText, this.searchOptions);
|
|
this.documentViewer.clearSelection();
|
|
}
|
|
|
|
#getSelectedText() {
|
|
this.documentViewer.addEventListener('textSelected', (q, selectedText) => {
|
|
this.selectedText = selectedText;
|
|
});
|
|
}
|
|
|
|
#clearSearchResultsWhenVisibilityChanged() {
|
|
const iframeWindow = this.#instance.UI.iframeWindow;
|
|
iframeWindow.addEventListener('visibilityChanged', (event: any) => {
|
|
if (event.detail.element !== 'searchPanel') {
|
|
return;
|
|
}
|
|
|
|
const clearSearchButton = iframeWindow.document.getElementsByClassName('clearSearch-button')[0] as HTMLButtonElement;
|
|
clearSearchButton?.click();
|
|
});
|
|
}
|
|
|
|
#navigateTo(pageNumber: number) {
|
|
if (this.#currentInternalPage !== pageNumber) {
|
|
this.documentViewer.displayPageLocation(pageNumber, 0, 0);
|
|
}
|
|
}
|
|
|
|
#disableHotkeys(): void {
|
|
DISABLED_HOTKEYS.forEach(key => this.#instance.UI.hotkeys.off(key));
|
|
}
|
|
|
|
#configureElements() {
|
|
this.#instance.UI.disableElements(USELESS_ELEMENTS);
|
|
}
|
|
|
|
#setSelectionMode(selectionMode: string): void {
|
|
const textTool = this.#instance.Core.Tools.TextTool as unknown as TextTool;
|
|
textTool.SELECTION_MODE = selectionMode;
|
|
}
|
|
|
|
#getInstance(htmlElement: HTMLElement) {
|
|
const options: WebViewerOptions = {
|
|
licenseKey: this.#licenseKey,
|
|
fullAPI: true,
|
|
path: this.#convertPath('/assets/wv-resources/11.0.0'),
|
|
css: this.#convertPath('/assets/pdftron/stylesheet.css'),
|
|
backendType: 'ems',
|
|
// This should be migrated to v11
|
|
ui: 'legacy',
|
|
};
|
|
|
|
// This should be migrated to v11
|
|
// https://docs.apryse.com/web/get-started/migrating-to-v11/
|
|
return WebViewer.Iframe(options, htmlElement);
|
|
}
|
|
|
|
#isElementActive(element: string): boolean {
|
|
return this.#instance.UI.isElementOpen(element);
|
|
}
|
|
|
|
#focusSearch() {
|
|
if (this.#isElementActive('textPopup')) {
|
|
this.#instance.UI.closeElements(['textPopup']);
|
|
}
|
|
if (this.#searchInput) {
|
|
this.#searchInput.focus();
|
|
}
|
|
if (this.#searchInput?.value?.length > 0) {
|
|
this.#searchInput.select();
|
|
}
|
|
}
|
|
|
|
#focusViewer() {
|
|
this.instance.UI.iframeWindow.document.getElementById('SearchPanel__input').blur();
|
|
this.instance.UI.iframeWindow.focus();
|
|
}
|
|
|
|
#clickSelectToolButton() {
|
|
const selectButton = this.instance.UI.iframeWindow.document.querySelector('[data-element="selectToolButton"]') as HTMLElement;
|
|
selectButton.click();
|
|
}
|
|
}
|