red-ui/apps/red-ui/src/app/modules/file-preview/file-preview-screen.component.ts
2022-03-21 11:18:05 +02:00

686 lines
28 KiB
TypeScript

import { ChangeDetectorRef, Component, HostListener, NgZone, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { ActivatedRoute, ActivatedRouteSnapshot, NavigationExtras, Router } from '@angular/router';
import { Core } from '@pdftron/webviewer';
import {
AutoUnsubscribe,
CircleButtonTypes,
CustomError,
Debounce,
ErrorService,
FilterService,
LoadingService,
log,
NestedFilter,
OnAttach,
OnDetach,
processFilters,
shareDistinctLast,
} from '@iqser/common-ui';
import { MatDialogRef, MatDialogState } from '@angular/material/dialog';
import { ManualRedactionEntryWrapper } from '@models/file/manual-redaction-entry.wrapper';
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
import { AnnotationDrawService } from './services/annotation-draw.service';
import { AnnotationProcessingService } from '../dossier/services/annotation-processing.service';
import { File, ViewMode } from '@red/domain';
import { PermissionsService } from '@services/permissions.service';
import { combineLatest, firstValueFrom, Observable, of, pairwise, timer } from 'rxjs';
import { UserPreferenceService } from '@services/user-preference.service';
import { clearStamps, download, handleFilterDelta, stampPDFPage } from '../../utils';
import { FileWorkloadComponent } from './components/file-workload/file-workload.component';
import { TranslateService } from '@ngx-translate/core';
import { FilesService } from '@services/entity-services/files.service';
import { FileManagementService } from '@services/entity-services/file-management.service';
import { catchError, debounceTime, map, startWith, switchMap, tap } from 'rxjs/operators';
import { FilesMapService } from '@services/entity-services/files-map.service';
import { WatermarkService } from '@shared/services/watermark.service';
import { ExcludedPagesService } from './services/excluded-pages.service';
import { ViewModeService } from './services/view-mode.service';
import { MultiSelectService } from './services/multi-select.service';
import { DocumentInfoService } from './services/document-info.service';
import { ReanalysisService } from '@services/reanalysis.service';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { SkippedService } from './services/skipped.service';
import { FilePreviewStateService } from './services/file-preview-state.service';
import { filePreviewScreenProviders } from './file-preview-providers';
import { ManualAnnotationService } from '@services/manual-annotation.service';
import { DossiersService } from '@services/dossiers/dossiers.service';
import { PageRotationService } from './services/page-rotation.service';
import { ComponentCanDeactivate } from '../../guards/can-deactivate.guard';
import { PdfViewer } from './services/pdf-viewer.service';
import { FilePreviewDialogService } from './services/file-preview-dialog.service';
import { FileDataService } from './services/file-data.service';
import { ALL_HOTKEYS } from './shared/constants';
import { NGXLogger } from 'ngx-logger';
import Annotation = Core.Annotations.Annotation;
import PDFNet = Core.PDFNet;
@Component({
templateUrl: './file-preview-screen.component.html',
styleUrls: ['./file-preview-screen.component.scss'],
providers: filePreviewScreenProviders,
})
export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnInit, OnDestroy, OnAttach, OnDetach, ComponentCanDeactivate {
readonly circleButtonTypes = CircleButtonTypes;
dialogRef: MatDialogRef<unknown>;
fullScreen = false;
selectedAnnotations: AnnotationWrapper[] = [];
displayPdfViewer = false;
readonly canPerformAnnotationActions$: Observable<boolean>;
readonly fileId = this.stateService.fileId;
readonly dossierId = this.stateService.dossierId;
readonly file$ = this.stateService.file$.pipe(tap(() => this._fileDataService.loadAnnotations()));
ready = false;
private _lastPage: string;
@ViewChild('fileWorkloadComponent') private readonly _workloadComponent: FileWorkloadComponent;
@ViewChild('annotationFilterTemplate', {
read: TemplateRef,
static: false,
})
private readonly _filterTemplate: TemplateRef<unknown>;
constructor(
readonly pdf: PdfViewer,
private readonly _router: Router,
private readonly _ngZone: NgZone,
private readonly _logger: NGXLogger,
private readonly _filesService: FilesService,
private readonly _errorService: ErrorService,
readonly stateService: FilePreviewStateService,
private readonly _filterService: FilterService,
readonly permissionsService: PermissionsService,
readonly multiSelectService: MultiSelectService,
private readonly _activatedRoute: ActivatedRoute,
private readonly _loadingService: LoadingService,
private readonly _skippedService: SkippedService,
readonly documentInfoService: DocumentInfoService,
private readonly _filesMapService: FilesMapService,
private readonly _dossiersService: DossiersService,
private readonly _fileDataService: FileDataService,
private readonly _viewModeService: ViewModeService,
readonly excludedPagesService: ExcludedPagesService,
private readonly _watermarkService: WatermarkService,
private readonly _translateService: TranslateService,
readonly userPreferenceService: UserPreferenceService,
private readonly _changeDetectorRef: ChangeDetectorRef,
private readonly _reanalysisService: ReanalysisService,
private readonly _dialogService: FilePreviewDialogService,
private readonly _pageRotationService: PageRotationService,
private readonly _annotationDrawService: AnnotationDrawService,
private readonly _fileManagementService: FileManagementService,
private readonly _manualAnnotationService: ManualAnnotationService,
private readonly _annotationProcessingService: AnnotationProcessingService,
) {
super();
this.canPerformAnnotationActions$ = this._canPerformAnnotationActions$;
document.documentElement.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement) {
this.fullScreen = false;
}
});
}
get changed() {
return this._pageRotationService.hasRotations();
}
private get _canPerformAnnotationActions$() {
const viewMode$ = this._viewModeService.viewMode$.pipe(tap(() => this.#deactivateMultiSelect()));
return combineLatest([this.stateService.file$, this.stateService.dossier$, viewMode$, this._viewModeService.compareMode$]).pipe(
map(
([file, dossier, viewMode]) =>
this.permissionsService.canPerformAnnotationActions(file, dossier) && viewMode === 'STANDARD',
),
shareDistinctLast(),
);
}
async save() {
await this._pageRotationService.applyRotation();
}
async updateViewMode(): Promise<void> {
if (!this.pdf.ready) {
return;
}
this.pdf.deleteAnnotations(this._fileDataService.textHighlights.map(a => a.id));
const annotations = this.pdf.getAnnotations(a => a.getCustomData('redact-manager'));
const redactions = annotations.filter(a => a.getCustomData('redaction'));
switch (this._viewModeService.viewMode) {
case 'STANDARD': {
this._setAnnotationsColor(redactions, 'annotationColor');
const wrappers = await this._fileDataService.annotations;
const ocrAnnotationIds = wrappers.filter(a => a.isOCR).map(a => a.id);
const standardEntries = annotations
.filter(a => a.getCustomData('changeLogRemoved') === 'false')
.filter(a => !ocrAnnotationIds.includes(a.Id));
const nonStandardEntries = annotations.filter(a => a.getCustomData('changeLogRemoved') === 'true');
this._setAnnotationsOpacity(standardEntries, true);
this.pdf.showAnnotations(standardEntries);
this.pdf.hideAnnotations(nonStandardEntries);
break;
}
case 'DELTA': {
const changeLogEntries = annotations.filter(a => a.getCustomData('changeLog') === 'true');
const nonChangeLogEntries = annotations.filter(a => a.getCustomData('changeLog') === 'false');
this._setAnnotationsColor(redactions, 'annotationColor');
this._setAnnotationsOpacity(changeLogEntries, true);
this.pdf.showAnnotations(changeLogEntries);
this.pdf.hideAnnotations(nonChangeLogEntries);
break;
}
case 'REDACTED': {
const nonRedactionEntries = annotations.filter(a => a.getCustomData('redaction') === 'false');
this._setAnnotationsOpacity(redactions);
this._setAnnotationsColor(redactions, 'redactionColor');
this.pdf.showAnnotations(redactions);
this.pdf.hideAnnotations(nonRedactionEntries);
break;
}
case 'TEXT_HIGHLIGHTS': {
this._loadingService.start();
this.pdf.hideAnnotations(annotations);
const highlights = await this._fileDataService.loadTextHighlights();
await this._annotationDrawService.drawAnnotations(highlights);
this._loadingService.stop();
}
}
await this._stampPDF();
await this.rebuildFilters();
}
ngOnDetach(): void {
this._pageRotationService.clearRotations();
this.displayPdfViewer = false;
super.ngOnDetach();
this._changeDetectorRef.markForCheck();
}
async ngOnAttach(previousRoute: ActivatedRouteSnapshot): Promise<boolean> {
const file = await this.stateService.file;
if (!file.canBeOpened) {
return this._router.navigate([this._dossiersService.find(this.dossierId)?.routerLink]);
}
this._viewModeService.compareMode = false;
this._viewModeService.switchToStandard();
await this.ngOnInit();
await this._fileDataService.loadRedactionLog();
this._lastPage = previousRoute.queryParams.page;
this._changeDetectorRef.markForCheck();
}
async ngOnInit(): Promise<void> {
this.ready = false;
this._loadingService.start();
await this.userPreferenceService.saveLastOpenedFileForDossier(this.dossierId, this.fileId);
this._subscribeToFileUpdates();
const file = await this.stateService.file;
if (file?.analysisRequired && !file.excludedFromAutomaticAnalysis) {
const reanalyzeFiles = this._reanalysisService.reanalyzeFilesForDossier([file], this.dossierId, { force: true });
await firstValueFrom(reanalyzeFiles);
}
this.displayPdfViewer = true;
}
async rebuildFilters(deletePreviousAnnotations = false) {
const startTime = new Date().getTime();
if (deletePreviousAnnotations) {
this.pdf.deleteAnnotations();
console.log(`[REDACTION] Delete previous annotations time: ${new Date().getTime() - startTime} ms`);
}
const processStartTime = new Date().getTime();
const visibleAnnotations = await this._fileDataService.visibleAnnotations;
const annotationFilters = this._annotationProcessingService.getAnnotationFilter(visibleAnnotations);
const primaryFilters = this._filterService.getGroup('primaryFilters')?.filters;
this._filterService.addFilterGroup({
slug: 'primaryFilters',
filterTemplate: this._filterTemplate,
filters: processFilters(primaryFilters, annotationFilters),
});
const secondaryFilters = this._filterService.getGroup('secondaryFilters')?.filters;
this._filterService.addFilterGroup({
slug: 'secondaryFilters',
filterTemplate: this._filterTemplate,
filters: processFilters(
secondaryFilters,
AnnotationProcessingService.secondaryAnnotationFilters(this._fileDataService.viewedPages),
),
});
this._logger.debug(`[REDACTION] Process time: ${new Date().getTime() - processStartTime} ms`);
this._logger.debug(`[REDACTION] Filter rebuild time: ${new Date().getTime() - startTime}`);
}
async handleAnnotationSelected(annotationIds: string[]) {
if (annotationIds.length > 0) {
this._workloadComponent.pagesPanelActive = false;
}
const visibleAnnotations = await this._fileDataService.visibleAnnotations;
this.selectedAnnotations = annotationIds
.map(id => visibleAnnotations.find(annotation => annotation.id === id))
.filter(ann => ann !== undefined);
if (this.selectedAnnotations.length > 1) {
this.multiSelectService.activate();
}
this._workloadComponent?.scrollToSelectedAnnotation();
this._changeDetectorRef.markForCheck();
}
@Debounce(10)
selectAnnotations(annotations: AnnotationWrapper[]) {
if (annotations) {
const annotationsToSelect = this.multiSelectService.isActive ? [...this.selectedAnnotations, ...annotations] : annotations;
this.pdf.selectAnnotations(annotationsToSelect, this.multiSelectService.isActive);
} else {
this.pdf.deselectAllAnnotations();
}
}
selectPage(pageNumber: number) {
this.pdf.navigateToPage(pageNumber);
this._workloadComponent?.scrollAnnotationsToPage(pageNumber, 'always');
this._lastPage = pageNumber.toString();
}
openManualAnnotationDialog(manualRedactionEntryWrapper: ManualRedactionEntryWrapper) {
this._ngZone.run(() => {
this.dialogRef = this._dialogService.openDialog(
'manualAnnotation',
null,
{ manualRedactionEntryWrapper, dossierId: this.dossierId },
async ({ manualRedactionEntry }: ManualRedactionEntryWrapper) => {
const addAnnotation$ = this._manualAnnotationService.addAnnotation(manualRedactionEntry, this.dossierId, this.fileId);
await firstValueFrom(addAnnotation$.pipe(catchError(() => of(undefined))));
await this._fileDataService.loadAnnotations();
},
);
});
}
toggleFullScreen() {
this.fullScreen = !this.fullScreen;
if (this.fullScreen) {
this._openFullScreen();
} else {
this.closeFullScreen();
}
}
handleArrowEvent($event: KeyboardEvent): void {
this._workloadComponent.handleKeyEvent($event);
}
@HostListener('window:keyup', ['$event'])
handleKeyEvent($event: KeyboardEvent) {
if (this._router.url.indexOf('/file/') < 0) {
return;
}
if (!ALL_HOTKEYS.includes($event.key) || this.dialogRef?.getState() === MatDialogState.OPEN) {
return;
}
if (['Escape'].includes($event.key)) {
this.fullScreen = false;
this.closeFullScreen();
this._changeDetectorRef.markForCheck();
}
if (['f', 'F'].includes($event.key)) {
// if you type in an input, don't toggle full-screen
if ($event.target instanceof HTMLInputElement || $event.target instanceof HTMLTextAreaElement) {
return;
}
this.toggleFullScreen();
return;
}
}
async viewerPageChanged($event: any) {
if (typeof $event !== 'number') {
return;
}
this._scrollViews();
this.multiSelectService.deactivate();
// Add current page in URL query params
const extras: NavigationExtras = {
queryParams: { page: $event },
queryParamsHandling: 'merge',
replaceUrl: true,
};
await this._router.navigate([], extras);
this._changeDetectorRef.markForCheck();
}
viewerReady() {
this.ready = true;
this.pdf.ready = true;
this._setExcludedPageStyles();
this.pdf.documentViewer.addEventListener('pageComplete', () => {
this._setExcludedPageStyles();
});
// Go to initial page from query params
const pageNumber: string = this._lastPage || this._activatedRoute.snapshot.queryParams.page;
if (pageNumber) {
setTimeout(() => {
this.selectPage(parseInt(pageNumber, 10));
this._scrollViews();
this._changeDetectorRef.markForCheck();
this._loadingService.stop();
});
} else {
this._loadingService.stop();
}
this._changeDetectorRef.markForCheck();
}
async annotationsChangedByReviewAction() {
this.multiSelectService.deactivate();
const file = await this.stateService.file;
await firstValueFrom(this._filesService.reload(this.dossierId, file));
}
closeFullScreen() {
if (!!document.fullscreenElement && document.exitFullscreen) {
document.exitFullscreen().then();
}
}
async switchView(viewMode: ViewMode) {
this._viewModeService.viewMode = viewMode;
await this.updateViewMode();
this._scrollViews();
}
async downloadOriginalFile(file: File) {
const originalFile = this._fileManagementService.downloadOriginalFile(
this.dossierId,
this.fileId,
'response',
file.cacheIdentifier,
);
download(await firstValueFrom(originalFile), file.filename);
}
loadAnnotations() {
const documentLoaded$ = this.pdf.documentLoaded$.pipe(tap(() => this.viewerReady()));
let start;
return combineLatest([documentLoaded$, this._fileDataService.annotations$]).pipe(
debounceTime(300),
log(),
tap(() => (start = new Date().getTime())),
map(([, annotations]) => annotations),
startWith({} as Record<string, AnnotationWrapper>),
pairwise(),
tap(annotations => this.deleteAnnotations(...annotations)),
switchMap(annotations => this.drawChangedAnnotations(...annotations)),
tap(() => this._logger.debug(`[ANNOTATIONS] Processing time: ${new Date().getTime() - start}`)),
tap(() => this.updateViewMode()),
);
}
deleteAnnotations(oldAnnotations: Record<string, AnnotationWrapper>, newAnnotations: Record<string, AnnotationWrapper>) {
const annotationsToDelete = Object.values(oldAnnotations).filter(oldAnnotation => !newAnnotations[oldAnnotation.id]);
if (annotationsToDelete.length === 0) {
return;
}
this._logger.debug('[ANNOTATIONS] To delete: ', annotationsToDelete);
this.pdf.deleteAnnotations(annotationsToDelete.map(annotation => annotation.id));
}
drawChangedAnnotations(oldAnnotations: Record<string, AnnotationWrapper>, newAnnotations: Record<string, AnnotationWrapper>) {
let annotationsToDraw: readonly AnnotationWrapper[];
if (this.pdf.hasAnnotations) {
annotationsToDraw = this.#getAnnotationsToDraw(newAnnotations, oldAnnotations);
} else {
annotationsToDraw = Object.values(newAnnotations);
}
if (annotationsToDraw.length === 0) {
return firstValueFrom(of({}));
}
this._logger.debug('[ANNOTATIONS] To draw: ', annotationsToDraw);
const annotationsToDrawIds = annotationsToDraw.map(a => a.annotationId);
this.pdf.deleteAnnotations(annotationsToDrawIds);
return this._cleanupAndRedrawAnnotations(annotationsToDraw);
}
#getAnnotationsToDraw(newAnnotations: Record<string, AnnotationWrapper>, oldAnnotations: Record<string, AnnotationWrapper>) {
return Object.values(newAnnotations).filter(newAnnotation => {
const oldAnnotation = oldAnnotations[newAnnotation.id];
if (!oldAnnotation) {
return true;
}
const changed = JSON.stringify(oldAnnotation) !== JSON.stringify(newAnnotation);
if (changed && this.userPreferenceService.areDevFeaturesEnabled) {
import('@iqser/common-ui').then(commonUi => {
this._logger.debug('[ANNOTATIONS] Changed annotation: ', {
value: oldAnnotation.value,
before: commonUi.deepDiffObj(newAnnotation, oldAnnotation),
after: commonUi.deepDiffObj(oldAnnotation, newAnnotation),
});
});
}
return changed;
});
}
async #deactivateMultiSelect() {
this.multiSelectService.deactivate();
this.pdf.deselectAllAnnotations();
await this.handleAnnotationSelected([]);
}
private _setExcludedPageStyles() {
const file = this._filesMapService.get(this.dossierId, this.fileId);
setTimeout(() => {
const iframeDoc = this.pdf.UI.iframeWindow.document;
const pageContainer = iframeDoc.getElementById(`pageWidgetContainer${this.pdf.currentPage}`);
if (pageContainer) {
if (file.excludedPages.includes(this.pdf.currentPage)) {
pageContainer.classList.add('excluded-page');
} else {
pageContainer.classList.remove('excluded-page');
}
}
}, 100);
}
private async _stampPDF() {
const pdfDoc = await this.pdf.documentViewer.getDocument().getPDFDoc();
const file = await this.stateService.file;
const allPages = [...Array(file.numberOfPages).keys()].map(page => page + 1);
if (!pdfDoc || !this.pdf.ready) {
return;
}
try {
await clearStamps(pdfDoc, this.pdf.PDFNet, allPages);
} catch (e) {
this._logger.debug('Error clearing stamps: ', e);
return;
}
if (this._viewModeService.isRedacted) {
const dossier = await this.stateService.dossier;
if (dossier.watermarkPreviewEnabled) {
await this._stampPreview(pdfDoc, dossier.dossierTemplateId);
}
} else {
await this._stampExcludedPages(pdfDoc, file.excludedPages);
}
this.pdf.documentViewer.refreshAll();
this.pdf.documentViewer.updateView([this.pdf.currentPage], this.pdf.currentPage);
this._changeDetectorRef.markForCheck();
}
private async _stampPreview(document: PDFNet.PDFDoc, dossierTemplateId: string) {
const watermark = await firstValueFrom(this._watermarkService.getWatermark(dossierTemplateId));
await stampPDFPage(
document,
this.pdf.PDFNet,
watermark.text,
watermark.fontSize,
watermark.fontType,
watermark.orientation,
watermark.opacity,
watermark.hexColor,
Array.from({ length: await document.getPageCount() }, (x, i) => i + 1),
);
}
private async _stampExcludedPages(document: PDFNet.PDFDoc, excludedPages: number[]) {
if (excludedPages && excludedPages.length > 0) {
await stampPDFPage(
document,
this.pdf.PDFNet,
this._translateService.instant('file-preview.excluded-from-redaction') as string,
17,
'courier',
'TOP_LEFT',
50,
'#dd4d50',
excludedPages,
);
}
}
private _subscribeToFileUpdates(): void {
this.addActiveScreenSubscription = this.loadAnnotations().subscribe();
// With changes monitoring, this should not be necessary
// this.addActiveScreenSubscription = timer(0, 5000)
// .pipe(
// switchMap(() => this.stateService.file$),
// switchMap(file => this._filesService.reload(this.dossierId, file)),
// )
// .subscribe();
this.addActiveScreenSubscription = this._dossiersService
.getEntityDeleted$(this.dossierId)
.pipe(tap(() => this._handleDeletedDossier()))
.subscribe();
this.addActiveScreenSubscription = this._filesMapService
.watchDeleted$(this.fileId)
.pipe(tap(() => this._handleDeletedFile()))
.subscribe();
this.addActiveScreenSubscription = this._skippedService.hideSkipped$
.pipe(tap(hideSkipped => this._handleIgnoreAnnotationsDrawing(hideSkipped)))
.subscribe();
}
private _handleDeletedDossier(): void {
this._errorService.set(
new CustomError(_('error.deleted-entity.file-dossier.label'), _('error.deleted-entity.file-dossier.action'), 'iqser:expand'),
);
}
private _handleDeletedFile(): void {
this._errorService.set(
new CustomError(_('error.deleted-entity.file.label'), _('error.deleted-entity.file.action'), 'iqser:expand'),
);
}
@Debounce(0)
private _scrollViews() {
this._workloadComponent?.scrollQuickNavigation();
this._workloadComponent?.scrollAnnotations();
}
private async _cleanupAndRedrawAnnotations(newAnnotations: readonly AnnotationWrapper[]) {
if (!this.pdf.ready) {
return;
}
const currentFilters = this._filterService.getGroup('primaryFilters')?.filters || [];
await this.rebuildFilters();
const startTime = new Date().getTime();
if (currentFilters) {
const visibleAnnotations = await this._fileDataService.visibleAnnotations;
this._handleDeltaAnnotationFilters(currentFilters, visibleAnnotations);
}
await this._annotationDrawService.drawAnnotations(newAnnotations);
this._logger.debug(`[ANNOTATIONS] Redraw time: ${new Date().getTime() - startTime} ms for ${newAnnotations.length} annotations`);
}
private _handleDeltaAnnotationFilters(currentFilters: NestedFilter[], newAnnotations: AnnotationWrapper[]) {
const primaryFilterGroup = this._filterService.getGroup('primaryFilters');
const primaryFilters = primaryFilterGroup.filters;
const secondaryFilters = this._filterService.getGroup('secondaryFilters').filters;
const hasAnyFilterSet = [...primaryFilters, ...secondaryFilters].find(f => f.checked || f.indeterminate);
if (!hasAnyFilterSet) {
return;
}
const newPageSpecificFilters = this._annotationProcessingService.getAnnotationFilter(newAnnotations);
handleFilterDelta(currentFilters, newPageSpecificFilters, primaryFilters);
this._filterService.addFilterGroup({
...primaryFilterGroup,
filters: primaryFilters,
});
}
private _openFullScreen() {
const documentElement = document.documentElement;
if (documentElement.requestFullscreen) {
documentElement.requestFullscreen().then();
}
}
private _handleIgnoreAnnotationsDrawing(hideSkipped: boolean): void {
const ignored = this.pdf.getAnnotations(a => a.getCustomData('skipped'));
if (hideSkipped) {
this.pdf.hideAnnotations(ignored);
} else {
this.pdf.showAnnotations(ignored);
}
}
private _setAnnotationsOpacity(annotations: Annotation[], restoreToOriginal: boolean = false) {
annotations.forEach(annotation => {
annotation['Opacity'] = restoreToOriginal ? parseFloat(annotation.getCustomData('opacity')) : 1;
});
}
private _setAnnotationsColor(annotations: Annotation[], customData: string) {
annotations.forEach(annotation => {
const color = this._annotationDrawService.convertColor(annotation.getCustomData(customData));
annotation['StrokeColor'] = color;
annotation['FillColor'] = color;
});
}
}