2024-12-04 13:07:50 +02:00

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();
}
}