Pull request #358: Text highlights

Merge in RED/ui from text-highlights to master

* commit 'c807524991ec556cbf61db82d53f0db1342ad9ca':
  Text highlight cleanup
  text-highlights
This commit is contained in:
Timo Bejan 2022-02-28 18:32:47 +01:00
commit 9dc94f6eca
37 changed files with 649 additions and 147 deletions

View File

@ -1,9 +1,10 @@
import { annotationTypesTranslations } from '../../translations/annotation-types-translations';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { IComment, IManualChange, IPoint, IRectangle, LogEntryStatus, ManualRedactionType } from '@red/domain';
import { IComment, IManualChange, ImportedRedaction, IPoint, IRectangle, LogEntryStatus, ManualRedactionType } from '@red/domain';
import { RedactionLogEntry } from '@models/file/redaction-log.entry';
export type AnnotationSuperType =
| 'text-highlight'
| 'suggestion-change-legal-basis'
| 'suggestion-recategorize-image'
| 'suggestion-add-dictionary'
@ -121,6 +122,10 @@ export class AnnotationWrapper {
}
get filterKey() {
if (this.isHighlight) {
return this.color;
}
return this.topLevelFilter ? this.superType : this.superType + this.type;
}
@ -154,6 +159,10 @@ export class AnnotationWrapper {
return this.superType === 'hint';
}
get isHighlight() {
return this.superType === 'text-highlight';
}
get isIgnoredHint() {
return this.superType === 'ignored-hint';
}
@ -235,10 +244,34 @@ export class AnnotationWrapper {
return this.legalBasisChangeValue || this.legalBasisValue;
}
get width(): number {
return Math.floor(this.positions[0].width);
}
get height(): number {
return Math.floor(this.positions[0].height);
}
get previewAnnotation() {
return this.isRedacted || this.isSuggestionAdd;
}
static fromHighlight(color: string, entry: ImportedRedaction) {
const annotationWrapper = new AnnotationWrapper();
annotationWrapper.annotationId = entry.id;
annotationWrapper.pageNumber = entry.positions[0].page;
annotationWrapper.superType = 'text-highlight';
annotationWrapper.typeValue = 'text-highlight';
annotationWrapper.value = 'Imported';
annotationWrapper.color = color;
annotationWrapper.positions = entry.positions;
annotationWrapper.firstTopLeftPoint = entry.positions[0]?.topLeft;
annotationWrapper.typeLabel = annotationTypesTranslations[annotationWrapper.superType];
return annotationWrapper;
}
static fromData(redactionLogEntry?: RedactionLogEntry) {
const annotationWrapper = new AnnotationWrapper();

View File

@ -7,6 +7,7 @@ import {
IViewedPage,
LogEntryStatus,
ManualRedactionType,
TextHighlightResponse,
ViewMode,
} from '@red/domain';
import { AnnotationWrapper } from './annotation.wrapper';
@ -19,6 +20,8 @@ export class FileDataModel {
allAnnotations: AnnotationWrapper[] = [];
readonly hasChangeLog$ = new BehaviorSubject<boolean>(false);
missingTypes = new Set<string>();
_textHighlightResponse: TextHighlightResponse;
textHighlightAnnotations: AnnotationWrapper[] = [];
constructor(
private readonly _file: File,
@ -39,7 +42,24 @@ export class FileDataModel {
this._buildAllAnnotations();
}
set textHighlights(textHighlightResponse: TextHighlightResponse) {
this._textHighlightResponse = textHighlightResponse;
const highlights = [];
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
for (const color of Object.keys(textHighlightResponse.redactionPerColor)) {
for (const entry of textHighlightResponse.redactionPerColor[color]) {
const annotation = AnnotationWrapper.fromHighlight(color, entry);
highlights.push(annotation);
}
}
this.textHighlightAnnotations = highlights;
}
getVisibleAnnotations(viewMode: ViewMode) {
if (viewMode === 'TEXT_HIGHLIGHTS') {
return this.textHighlightAnnotations;
}
return this.allAnnotations.filter(annotation => {
if (viewMode === 'STANDARD') {
return !annotation.isChangeLogRemoved;

View File

@ -25,6 +25,8 @@ import { OverlayModule } from '@angular/cdk/overlay';
import { SharedDossiersModule } from './shared/shared-dossiers.module';
import { ResizeAnnotationDialogComponent } from './dialogs/resize-annotation-dialog/resize-annotation-dialog.component';
import { EditDossierTeamComponent } from './dialogs/edit-dossier-dialog/edit-dossier-team/edit-dossier-team.component';
import { HighlightActionDialogComponent } from './screens/file-preview-screen/dialogs/highlight-action-dialog/highlight-action-dialog.component';
import { ColorPickerModule } from 'ngx-color-picker';
const screens = [SearchScreenComponent];
@ -39,6 +41,7 @@ const dialogs = [
AssignReviewerApproverDialogComponent,
ChangeLegalBasisDialogComponent,
RecategorizeImageDialogComponent,
HighlightActionDialogComponent,
];
const components = [
@ -58,6 +61,14 @@ const services = [DossiersDialogService, ManualAnnotationService, AnnotationProc
@NgModule({
declarations: [...components],
providers: [...services],
imports: [CommonModule, SharedModule, SharedDossiersModule, FileUploadDownloadModule, DossiersRoutingModule, OverlayModule],
imports: [
CommonModule,
SharedModule,
SharedDossiersModule,
FileUploadDownloadModule,
DossiersRoutingModule,
OverlayModule,
ColorPickerModule,
],
})
export class DossiersModule {}

View File

@ -5,7 +5,7 @@
<div>
<strong>{{ annotation.typeLabel | translate }}</strong>
</div>
<div *ngIf="annotation?.type !== 'manual'">
<div *ngIf="annotation.type !== 'manual' && !annotation.isHighlight">
<strong>
<span>{{ annotation.descriptor | translate }}</span
>: </strong
@ -14,6 +14,12 @@
<div *ngIf="annotation.shortContent && !annotation.isHint">
<strong><span translate="content"></span>: </strong>{{ annotation.shortContent }}
</div>
<div *ngIf="annotation.isHighlight">
<strong><span translate="color"></span>: </strong>{{ annotation.color }}
</div>
<div *ngIf="annotation.isHighlight">
<strong><span translate="size"></span>: </strong>{{ annotation.width }}x{{ annotation.height }} px
</div>
</div>
<div class="active-icon-marker-container">

View File

@ -1,46 +1,75 @@
<div
(click)="annotationClicked(annotation, $event)"
*ngFor="let annotation of annotations"
[attr.annotation-id]="annotation.id"
[attr.annotation-page]="activeViewerPage"
[class.active]="isSelected(annotation.annotationId)"
[class.multi-select-active]="multiSelectService.active$ | async"
class="annotation-wrapper"
>
<div class="active-bar-marker"></div>
<div [class.removed]="annotation.isChangeLogRemoved" class="annotation">
<redaction-annotation-card
[annotation]="annotation"
[isSelected]="isSelected(annotation.annotationId)"
[matTooltip]="annotation.content"
matTooltipPosition="above"
></redaction-annotation-card>
<div class="actions-wrapper">
<div
(click)="comments.toggleExpandComments($event)"
[matTooltip]="'comments.comments' | translate: { count: annotation.comments?.length }"
class="comments-counter"
matTooltipPosition="above"
>
<mat-icon svgIcon="red:comment"></mat-icon>
{{ annotation.comments.length }}
</div>
<div *ngIf="multiSelectService.inactive$ | async" class="actions">
<ng-container
[ngTemplateOutletContext]="{ annotation: annotation }"
[ngTemplateOutlet]="annotationActionsTemplate"
></ng-container>
</div>
<ng-container *ngFor="let annotation of annotations; let idx = index">
<div *ngIf="showHighlightGroup(idx) as highlightGroup" class="workload-separator">
<div>
<redaction-type-annotation-icon [annotation]="annotation" class="mr-8"></redaction-type-annotation-icon>
<span [translateParams]="highlightGroup" [translate]="'highlights'" class="all-caps-label"></span>
</div>
<redaction-comments #comments [annotation]="annotation"></redaction-comments>
<div *ngIf="state.isWritable$ | async">
<iqser-circle-button
(action)="convertHighlights(highlightGroup)"
[size]="28"
[tooltip]="'file-preview.highlights.convert' | translate"
[type]="circleButtonTypes.dark"
class="mr-2"
icon="iqser:plus"
tooltipPosition="above"
></iqser-circle-button>
<iqser-circle-button
(action)="removeHighlights(highlightGroup)"
[size]="28"
[tooltip]="'file-preview.highlights.remove' | translate"
[type]="circleButtonTypes.dark"
icon="iqser:trash"
tooltipPosition="above"
></iqser-circle-button>
</div>
</div>
<redaction-annotation-details [annotation]="annotation" [isSelected]="isSelected(annotation.id)"></redaction-annotation-details>
</div>
<div
(click)="annotationClicked(annotation, $event)"
[attr.annotation-id]="annotation.id"
[attr.annotation-page]="activeViewerPage"
[class.active]="isSelected(annotation.annotationId)"
[class.multi-select-active]="multiSelectService.active$ | async"
class="annotation-wrapper"
>
<div class="active-bar-marker"></div>
<div [class.removed]="annotation.isChangeLogRemoved" class="annotation">
<redaction-annotation-card
[annotation]="annotation"
[isSelected]="isSelected(annotation.annotationId)"
[matTooltip]="annotation.content"
matTooltipPosition="above"
></redaction-annotation-card>
<div *ngIf="!annotation.isHighlight" class="actions-wrapper">
<div
(click)="comments.toggleExpandComments($event)"
[matTooltip]="'comments.comments' | translate: { count: annotation.comments?.length }"
class="comments-counter"
matTooltipPosition="above"
>
<mat-icon svgIcon="red:comment"></mat-icon>
{{ annotation.comments.length }}
</div>
<div *ngIf="multiSelectService.inactive$ | async" class="actions">
<ng-container
[ngTemplateOutletContext]="{ annotation: annotation }"
[ngTemplateOutlet]="annotationActionsTemplate"
></ng-container>
</div>
</div>
<redaction-comments #comments [annotation]="annotation"></redaction-comments>
</div>
<redaction-annotation-details [annotation]="annotation" [isSelected]="isSelected(annotation.id)"></redaction-annotation-details>
</div>
</ng-container>
<ng-container *ngIf="annotationReferencesService.annotation$ | async">
<redaction-annotation-references-list

View File

@ -76,3 +76,7 @@
}
}
}
.workload-separator > div {
display: flex;
}

View File

@ -1,11 +1,20 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges, TemplateRef } from '@angular/core';
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
import { FilterService, IqserEventTarget } from '@iqser/common-ui';
import { CircleButtonTypes, FilterService, IqserEventTarget } from '@iqser/common-ui';
import { MultiSelectService } from '../../services/multi-select.service';
import { AnnotationReferencesService } from '../../services/annotation-references.service';
import { ViewModeService } from '../../services/view-mode.service';
import { FilePreviewStateService } from '../../services/file-preview-state.service';
import { UserPreferenceService } from '../../../../../../services/user-preference.service';
import { UserPreferenceService } from '@services/user-preference.service';
import { ViewModeService } from '../../services/view-mode.service';
import { BehaviorSubject } from 'rxjs';
import { DossiersDialogService } from '../../../../services/dossiers-dialog.service';
import { TextHighlightOperation } from '@red/domain';
interface HighlightGroup {
startIdx: number;
color: string;
length: number;
}
@Component({
selector: 'redaction-annotations-list',
@ -14,6 +23,8 @@ import { UserPreferenceService } from '../../../../../../services/user-preferenc
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AnnotationsListComponent implements OnChanges {
readonly circleButtonTypes = CircleButtonTypes;
@Input() annotations: AnnotationWrapper[];
@Input() selectedAnnotations: AnnotationWrapper[];
@Input() annotationActionsTemplate: TemplateRef<unknown>;
@ -23,19 +34,36 @@ export class AnnotationsListComponent implements OnChanges {
@Output() readonly selectAnnotations = new EventEmitter<AnnotationWrapper[]>();
@Output() readonly deselectAnnotations = new EventEmitter<AnnotationWrapper[]>();
highlightGroups$ = new BehaviorSubject<HighlightGroup[]>([]);
constructor(
readonly multiSelectService: MultiSelectService,
readonly viewModeService: ViewModeService,
readonly annotationReferencesService: AnnotationReferencesService,
readonly state: FilePreviewStateService,
private readonly _filterService: FilterService,
private readonly _state: FilePreviewStateService,
private readonly _userPreferenceService: UserPreferenceService,
private readonly _viewModeService: ViewModeService,
private readonly _dialogService: DossiersDialogService,
) {}
convertHighlights(highlightGroup: HighlightGroup): void {
const data = this._getActionData(highlightGroup, TextHighlightOperation.CONVERT);
this._dialogService.openDialog('highlightAction', null, data);
}
removeHighlights(highlightGroup: HighlightGroup): void {
const data = this._getActionData(highlightGroup, TextHighlightOperation.REMOVE);
this._dialogService.openDialog('highlightAction', null, data);
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.annotations && this.annotations) {
if (changes.annotations && this.annotations && !this._viewModeService.isTextHighlights) {
this.annotations = this.annotations.sort(this.annotationsPositionCompare);
}
if (this._viewModeService.isTextHighlights) {
this._updateHighlightGroups();
}
}
annotationClicked(annotation: AnnotationWrapper, $event: MouseEvent): void {
@ -80,4 +108,37 @@ export class AnnotationsListComponent implements OnChanges {
return first.x < second.y ? -1 : 1;
}
}
showHighlightGroup(idx: number): HighlightGroup {
return this._viewModeService.isTextHighlights && this.highlightGroups$.value.find(h => h.startIdx === idx);
}
private _getActionData(highlightGroup: HighlightGroup, operation: TextHighlightOperation) {
return {
dossierId: this.state.dossierId,
fileId: this.state.fileId,
color: highlightGroup.color,
operation,
};
}
private _updateHighlightGroups(): void {
if (!this.annotations?.length) {
return;
}
const highlightGroups: HighlightGroup[] = [];
let lastGroup: HighlightGroup;
for (let idx = 0; idx < this.annotations.length; ++idx) {
if (idx === 0 || this.annotations[idx].color !== this.annotations[idx - 1].color) {
if (lastGroup) {
highlightGroups.push(lastGroup);
}
lastGroup = { startIdx: idx, length: 1, color: this.annotations[idx].color };
} else {
lastGroup.length += 1;
}
}
highlightGroups.push(lastGroup);
this.highlightGroups$.next(highlightGroups);
}
}

View File

@ -10,7 +10,8 @@
</div>
<ng-template #selectAndFilter>
<div class="right-title heading" translate="file-preview.tabs.annotations.label">
<div class="right-title heading">
{{ title$ | async | translate }}
<div>
<div
(click)="multiSelectService.activate()"
@ -111,16 +112,18 @@
<div style="overflow: hidden; width: 100%">
<div
*ngIf="(isHighlights$ | async) === false"
[attr.anotation-page-header]="activeViewerPage"
[class.padding-left-0]="currentPageIsExcluded"
[hidden]="excludedPagesService.shown$ | async"
class="page-separator"
class="workload-separator"
>
<span *ngIf="!!activeViewerPage" class="flex-align-items-center">
<iqser-circle-button
(action)="excludedPagesService.toggle()"
*ngIf="currentPageIsExcluded"
[tooltip]="'file-preview.excluded-from-redaction' | translate | capitalize"
class="excluded"
icon="red:exclude-pages"
tooltipPosition="above"
></iqser-circle-button>

View File

@ -115,25 +115,6 @@
}
}
.page-separator {
border-bottom: 1px solid variables.$separator;
height: 32px;
box-sizing: border-box;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
background-color: variables.$grey-6;
> div {
display: flex;
> div:not(:last-child) {
margin-right: 8px;
}
}
}
.annotations {
overflow: hidden;
width: 100%;
@ -157,7 +138,7 @@
padding-left: 0 !important;
}
::ng-deep .page-separator iqser-circle-button mat-icon {
::ng-deep .workload-separator iqser-circle-button.excluded mat-icon {
color: var(--iqser-primary);
}

View File

@ -34,6 +34,8 @@ import { MultiSelectService } from '../../services/multi-select.service';
import { DocumentInfoService } from '../../services/document-info.service';
import { SkippedService } from '../../services/skipped.service';
import { FilePreviewStateService } from '../../services/file-preview-state.service';
import { ViewModeService } from '../../services/view-mode.service';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
const COMMAND_KEY_ARRAY = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Escape'];
const ALL_HOTKEY_ARRAY = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'];
@ -67,6 +69,8 @@ export class FileWorkloadComponent {
readonly multiSelectActive$: Observable<boolean>;
readonly multiSelectInactive$: Observable<boolean>;
readonly showExcludedPages$: Observable<boolean>;
readonly title$: Observable<string>;
readonly isHighlights$: Observable<boolean>;
private _annotations$ = new BehaviorSubject<AnnotationWrapper[]>([]);
@ViewChild('annotationsElement') private readonly _annotationsElement: ElementRef;
@ViewChild('quickNavigation') private readonly _quickNavigationElement: ElementRef;
@ -78,6 +82,7 @@ export class FileWorkloadComponent {
readonly multiSelectService: MultiSelectService,
readonly documentInfoService: DocumentInfoService,
readonly excludedPagesService: ExcludedPagesService,
private readonly _viewModeService: ViewModeService,
private readonly _changeDetectorRef: ChangeDetectorRef,
private readonly _permissionsService: PermissionsService,
private readonly _annotationProcessingService: AnnotationProcessingService,
@ -86,6 +91,8 @@ export class FileWorkloadComponent {
this.multiSelectActive$ = this._multiSelectActive$;
this.multiSelectInactive$ = this._multiSelectInactive$;
this.showExcludedPages$ = this._showExcludedPages$;
this.isHighlights$ = this._isHighlights$;
this.title$ = this._title$;
}
@Input()
@ -112,6 +119,16 @@ export class FileWorkloadComponent {
);
}
private get _title$(): Observable<string> {
return this.isHighlights$.pipe(
map(isHighlights => (isHighlights ? _('file-preview.tabs.highlights.label') : _('file-preview.tabs.annotations.label'))),
);
}
private get _isHighlights$(): Observable<boolean> {
return this._viewModeService.viewMode$.pipe(map(() => this._viewModeService.isTextHighlights));
}
private get _multiSelectInactive$() {
return this.multiSelectService.inactive$.pipe(
tap(value => {

View File

@ -26,9 +26,11 @@ export class TypeAnnotationIconComponent implements OnChanges {
return;
}
const { isSuggestion, isRecommendation, isSkipped, isDeclinedSuggestion, isHint, isIgnoredHint } = this.annotation;
const { isHighlight, isSuggestion, isRecommendation, isSkipped, isDeclinedSuggestion, isHint, isIgnoredHint } = this.annotation;
if (this.annotation.isSuperTypeBasedColor) {
if (isHighlight) {
this.color = this.annotation.color;
} else if (this.annotation.isSuperTypeBasedColor) {
this.color = this._dictionariesMapService.getDictionaryColor(this.annotation.superType, this._dossierTemplateId);
} else {
this.color = this._dictionariesMapService.getDictionaryColor(this.annotation.type, this._dossierTemplateId);
@ -36,6 +38,7 @@ export class TypeAnnotationIconComponent implements OnChanges {
this.type =
isSuggestion || isDeclinedSuggestion ? 'rhombus' : isHint || isIgnoredHint ? 'circle' : isRecommendation ? 'hexagon' : 'square';
this.label = isSuggestion || isDeclinedSuggestion ? 'S' : isSkipped ? 'S' : this.annotation.type[0].toUpperCase();
this.label = isHighlight ? '' : isSuggestion || isDeclinedSuggestion || isSkipped ? 'S' : this.annotation.type[0].toUpperCase();
}
}

View File

@ -30,4 +30,15 @@
>
{{ 'file-preview.redacted' | translate }}
</button>
<button
(click)="switchView.emit('TEXT_HIGHLIGHTS')"
[class.active]="viewModeService.isTextHighlights"
[disabled]="(canSwitchToRedactedView$ | async) === false"
[matTooltip]="'file-preview.text-highlights-tooltip' | translate"
class="red-tab"
>
<!-- iqserHelpMode="text_highlights_view"-->
{{ 'file-preview.text-highlights' | translate }}
</button>
</ng-container>

View File

@ -0,0 +1,35 @@
<section class="dialog">
<form (submit)="save()" [formGroup]="form">
<div [translate]="title" class="dialog-header heading-l"></div>
<div class="dialog-content">
<div [translate]="details" class="mb-24"></div>
<div class="iqser-input-group required w-150">
<label translate="highlight-action-dialog.form.color.label"></label>
<input class="hex-color-input" formControlName="color" name="color" type="text" />
<div
[colorPicker]="form.get('color').value"
[cpDisabled]="true"
[style.background]="form.get('color').value"
class="input-icon"
></div>
</div>
<div class="iqser-input-group">
<mat-checkbox color="primary" formControlName="confirmation" name="confirmation">
{{ confirmationMessage | translate }}
</mat-checkbox>
</div>
</div>
<div class="dialog-actions">
<button [disabled]="form.invalid" color="primary" mat-flat-button type="submit">
{{ saveMessage | translate }}
</button>
<div class="all-caps-label cancel" mat-dialog-close translate="highlight-action-dialog.actions.cancel"></div>
</div>
</form>
<iqser-circle-button (action)="close()" class="dialog-close" icon="iqser:close"></iqser-circle-button>
</section>

View File

@ -0,0 +1,70 @@
import { Component, Inject, Injector } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TextHighlightOperation } from '@red/domain';
import { BaseDialogComponent, LoadingService } from '@iqser/common-ui';
import { TextHighlightService } from '../../../../services/text-highlight.service';
import { firstValueFrom } from 'rxjs';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
export interface HighlightActionData {
readonly operation: TextHighlightOperation;
readonly color: string;
readonly dossierId: string;
readonly fileId: string;
}
@Component({
templateUrl: './highlight-action-dialog.component.html',
})
export class HighlightActionDialogComponent extends BaseDialogComponent {
readonly title: string;
readonly details: string;
readonly confirmationMessage: string;
readonly saveMessage: string;
constructor(
private readonly _formBuilder: FormBuilder,
protected readonly _injector: Injector,
protected readonly _dialogRef: MatDialogRef<HighlightActionDialogComponent>,
private readonly _textHighlightService: TextHighlightService,
private readonly _loadingService: LoadingService,
@Inject(MAT_DIALOG_DATA) readonly data: HighlightActionData,
) {
super(_injector, _dialogRef);
this.form = this._getForm();
this.initialFormValue = this.form.getRawValue();
this.title =
data.operation === TextHighlightOperation.CONVERT
? _('highlight-action-dialog.convert.title')
: _('highlight-action-dialog.remove.title');
this.details =
data.operation === TextHighlightOperation.CONVERT
? _('highlight-action-dialog.convert.details')
: _('highlight-action-dialog.remove.details');
this.confirmationMessage =
data.operation === TextHighlightOperation.CONVERT
? _('highlight-action-dialog.convert.confirmation')
: _('highlight-action-dialog.remove.confirmation');
this.saveMessage =
data.operation === TextHighlightOperation.CONVERT
? _('highlight-action-dialog.convert.save')
: _('highlight-action-dialog.remove.save');
}
async save(): Promise<void> {
this._loadingService.start();
const { dossierId, fileId, color, operation } = this.data;
await firstValueFrom(this._textHighlightService.performHighlightsAction(dossierId, fileId, [color], operation));
this._loadingService.stop();
this._dialogRef.close(true);
}
private _getForm(): FormGroup {
return this._formBuilder.group({
color: [{ value: this.data.color, disabled: true }, Validators.required],
confirmation: [false, Validators.requiredTrue],
});
}
}

View File

@ -152,6 +152,16 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
return;
}
const textHighlightAnnotationIds = this._fileData.textHighlightAnnotations.map(a => a.id);
const textHighlightAnnotations = this._getAnnotations((a: Core.Annotations.Annotation) =>
textHighlightAnnotationIds.includes(a.Id),
);
this._instance.Core.annotationManager.deleteAnnotations(textHighlightAnnotations, {
imported: true,
force: true,
});
const ocrAnnotationIds = this._fileData.allAnnotations.filter(a => a.isOCR).map(a => a.id);
const annotations = this._getAnnotations(a => a.getCustomData('redact-manager'));
const redactions = annotations.filter(a => a.getCustomData('redaction'));
@ -165,7 +175,7 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
const nonStandardEntries = annotations.filter(a => a.getCustomData('changeLogRemoved') === 'true');
this._setAnnotationsOpacity(standardEntries, true);
this._show(standardEntries);
this._hide(nonStandardEntries);
this._hide([...nonStandardEntries]);
break;
}
case 'DELTA': {
@ -174,7 +184,7 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
this._setAnnotationsColor(redactions, 'annotationColor');
this._setAnnotationsOpacity(changeLogEntries, true);
this._show(changeLogEntries);
this._hide(nonChangeLogEntries);
this._hide([...nonChangeLogEntries]);
break;
}
case 'REDACTED': {
@ -182,9 +192,23 @@ export class FilePreviewScreenComponent extends AutoUnsubscribe implements OnIni
this._setAnnotationsOpacity(redactions);
this._setAnnotationsColor(redactions, 'redactionColor');
this._show(redactions);
this._hide(nonRedactionEntries);
this._hide([...nonRedactionEntries]);
break;
}
case 'TEXT_HIGHLIGHTS': {
this._loadingService.start();
const textHighlights = await firstValueFrom(this._pdfViewerDataService.loadTextHighlightsFor(this.dossierId, this.fileId));
this._hide(annotations);
this._fileData.textHighlights = textHighlights;
await this._annotationDrawService.drawAnnotations(
this.activeViewer,
this._fileData.textHighlightAnnotations,
this.dossierId,
this.fileId,
false,
);
this._loadingService.stop();
}
}
await this._stampPDF();

View File

@ -156,66 +156,85 @@ export class AnnotationDrawService {
compareMode: boolean,
) {
const pageNumber = compareMode ? annotationWrapper.pageNumber * 2 - 1 : annotationWrapper.pageNumber;
const dossierTemplateId = this._dossiersService.find(dossierId).dossierTemplateId;
let annotation: Core.Annotations.RectangleAnnotation | Core.Annotations.TextHighlightAnnotation;
if (annotationWrapper.rectangle || annotationWrapper.isImage) {
annotation = new activeViewer.Core.Annotations.RectangleAnnotation();
if (annotationWrapper.superType === 'text-highlight') {
const rectangleAnnot = new activeViewer.Core.Annotations.RectangleAnnotation();
const pageHeight = activeViewer.Core.documentViewer.getPageHeight(pageNumber);
const firstPosition = annotationWrapper.positions[0];
annotation.X = firstPosition.topLeft.x;
annotation.Y = pageHeight - (firstPosition.topLeft.y + firstPosition.height);
annotation.Width = firstPosition.width;
annotation.FillColor = this.getAndConvertColor(
const rectangle: IRectangle = annotationWrapper.positions[0];
rectangleAnnot.PageNumber = pageNumber;
rectangleAnnot.X = rectangle.topLeft.x;
rectangleAnnot.Y = pageHeight - (rectangle.topLeft.y + rectangle.height);
rectangleAnnot.Width = rectangle.width;
rectangleAnnot.Height = rectangle.height;
rectangleAnnot.ReadOnly = true;
rectangleAnnot.StrokeColor = this.convertColor(activeViewer, annotationWrapper.color);
rectangleAnnot.StrokeThickness = 1;
rectangleAnnot.Id = annotationWrapper.id;
return rectangleAnnot;
} else {
const dossierTemplateId = this._dossiersService.find(dossierId).dossierTemplateId;
let annotation: Core.Annotations.RectangleAnnotation | Core.Annotations.TextHighlightAnnotation;
if (annotationWrapper.rectangle || annotationWrapper.isImage) {
annotation = new activeViewer.Core.Annotations.RectangleAnnotation();
const pageHeight = activeViewer.Core.documentViewer.getPageHeight(pageNumber);
const firstPosition = annotationWrapper.positions[0];
annotation.X = firstPosition.topLeft.x;
annotation.Y = pageHeight - (firstPosition.topLeft.y + firstPosition.height);
annotation.Width = firstPosition.width;
annotation.FillColor = this.getAndConvertColor(
activeViewer,
dossierTemplateId,
annotationWrapper.superType,
annotationWrapper.type,
);
annotation.Opacity = annotationWrapper.isChangeLogRemoved
? AnnotationDrawService.DEFAULT_REMOVED_ANNOTATION_OPACITY
: AnnotationDrawService.DEFAULT_RECTANGLE_ANNOTATION_OPACITY;
annotation.Height = firstPosition.height;
annotation.Intensity = 100;
} else {
annotation = new activeViewer.Core.Annotations.TextHighlightAnnotation();
annotation.Quads = this._rectanglesToQuads(annotationWrapper.positions, activeViewer, pageNumber);
annotation.Opacity = annotationWrapper.isChangeLogRemoved
? AnnotationDrawService.DEFAULT_REMOVED_ANNOTATION_OPACITY
: AnnotationDrawService.DEFAULT_TEXT_ANNOTATION_OPACITY;
}
annotation.setContents(annotationWrapper.content);
annotation.PageNumber = pageNumber;
annotation.StrokeColor = this.getAndConvertColor(
activeViewer,
dossierTemplateId,
annotationWrapper.superType,
annotationWrapper.type,
);
annotation.Opacity = annotationWrapper.isChangeLogRemoved
? AnnotationDrawService.DEFAULT_REMOVED_ANNOTATION_OPACITY
: AnnotationDrawService.DEFAULT_RECTANGLE_ANNOTATION_OPACITY;
annotation.Height = firstPosition.height;
annotation.Intensity = 100;
} else {
annotation = new activeViewer.Core.Annotations.TextHighlightAnnotation();
annotation.Quads = this._rectanglesToQuads(annotationWrapper.positions, activeViewer, pageNumber);
annotation.Opacity = annotationWrapper.isChangeLogRemoved
? AnnotationDrawService.DEFAULT_REMOVED_ANNOTATION_OPACITY
: AnnotationDrawService.DEFAULT_TEXT_ANNOTATION_OPACITY;
annotation.Id = annotationWrapper.id;
annotation.ReadOnly = true;
// change log entries are drawn lighter
annotation.Hidden =
annotationWrapper.isChangeLogRemoved ||
(this._skippedService.hideSkipped && annotationWrapper.isSkipped) ||
annotationWrapper.isOCR ||
annotationWrapper.hidden;
annotation.setCustomData('redact-manager', 'true');
annotation.setCustomData('redaction', String(annotationWrapper.previewAnnotation));
annotation.setCustomData('highlight', String(annotationWrapper.isHighlight));
annotation.setCustomData('skipped', String(annotationWrapper.isSkipped));
annotation.setCustomData('changeLog', String(annotationWrapper.isChangeLogEntry));
annotation.setCustomData('changeLogRemoved', String(annotationWrapper.isChangeLogRemoved));
annotation.setCustomData('opacity', String(annotation.Opacity));
annotation.setCustomData('redactionColor', String(this.getColor(activeViewer, dossierTemplateId, 'redaction', 'redaction')));
annotation.setCustomData(
'annotationColor',
String(this.getColor(activeViewer, dossierTemplateId, annotationWrapper.superType, annotationWrapper.type)),
);
return annotation;
}
annotation.setContents(annotationWrapper.content);
annotation.PageNumber = pageNumber;
annotation.StrokeColor = this.getAndConvertColor(
activeViewer,
dossierTemplateId,
annotationWrapper.superType,
annotationWrapper.type,
);
annotation.Id = annotationWrapper.id;
annotation.ReadOnly = true;
// change log entries are drawn lighter
annotation.Hidden =
annotationWrapper.isChangeLogRemoved ||
(this._skippedService.hideSkipped && annotationWrapper.isSkipped) ||
annotationWrapper.isOCR ||
annotationWrapper.hidden;
annotation.setCustomData('redact-manager', 'true');
annotation.setCustomData('redaction', String(annotationWrapper.previewAnnotation));
annotation.setCustomData('skipped', String(annotationWrapper.isSkipped));
annotation.setCustomData('changeLog', String(annotationWrapper.isChangeLogEntry));
annotation.setCustomData('changeLogRemoved', String(annotationWrapper.isChangeLogRemoved));
annotation.setCustomData('opacity', String(annotation.Opacity));
annotation.setCustomData('redactionColor', String(this.getColor(activeViewer, dossierTemplateId, 'redaction', 'redaction')));
annotation.setCustomData(
'annotationColor',
String(this.getColor(activeViewer, dossierTemplateId, annotationWrapper.superType, annotationWrapper.type)),
);
return annotation;
}
private _rectanglesToQuads(positions: IRectangle[], activeViewer: WebViewerInstance, pageNumber: number): any[] {

View File

@ -43,6 +43,10 @@ export class ViewModeService {
return this._viewMode$.value === 'REDACTED';
}
get isTextHighlights() {
return this._viewMode$.value === 'TEXT_HIGHLIGHTS';
}
get isCompare() {
return this._compareMode$.value;
}
@ -63,6 +67,10 @@ export class ViewModeService {
this._switchTo('REDACTED');
}
switchToHighlights() {
this._switchTo('TEXT_HIGHLIGHTS');
}
private _switchTo(mode: ViewMode) {
this._viewMode$.next(mode);
}

View File

@ -58,7 +58,13 @@ export class AnnotationProcessingService {
} else {
// top level filter
if (topLevelFilter) {
this._createParentFilter(a.superType, filterMap, filters);
this._createParentFilter(
a.isHighlight ? a.filterKey : a.superType,
filterMap,
filters,
a.isHighlight,
a.isHighlight ? a.color : null,
);
} else {
let parentFilter = filterMap.get(a.superType);
if (!parentFilter) {
@ -124,18 +130,28 @@ export class AnnotationProcessingService {
}
obj.forEach((values, page) => {
obj.set(page, this._sortAnnotations(values));
if (!values[0].isHighlight) {
obj.set(page, this._sortAnnotations(values));
}
});
return obj;
}
private _createParentFilter(key: string, filterMap: Map<string, INestedFilter>, filters: INestedFilter[]) {
private _createParentFilter(
key: string,
filterMap: Map<string, INestedFilter>,
filters: INestedFilter[],
skipTranslation = false,
color?: string,
) {
const filter: INestedFilter = new NestedFilter({
id: key,
topLevelFilter: true,
matches: 1,
label: annotationTypesTranslations[key],
label: skipTranslation ? key : annotationTypesTranslations[key],
skipTranslation,
color,
});
filterMap.set(key, filter);
filters.push(filter);

View File

@ -11,6 +11,7 @@ import { ChangeLegalBasisDialogComponent } from '../dialogs/change-legal-basis-d
import { RecategorizeImageDialogComponent } from '../dialogs/recategorize-image-dialog/recategorize-image-dialog.component';
import { ConfirmationDialogComponent, DialogConfig, DialogService, largeDialogConfig } from '@iqser/common-ui';
import { ResizeAnnotationDialogComponent } from '../dialogs/resize-annotation-dialog/resize-annotation-dialog.component';
import { HighlightActionDialogComponent } from '../screens/file-preview-screen/dialogs/highlight-action-dialog/highlight-action-dialog.component';
type DialogType =
| 'confirm'
@ -23,7 +24,8 @@ type DialogType =
| 'removeAnnotations'
| 'resizeAnnotation'
| 'forceAnnotation'
| 'manualAnnotation';
| 'manualAnnotation'
| 'highlightAction';
@Injectable()
export class DossiersDialogService extends DialogService<DialogType> {
@ -67,6 +69,9 @@ export class DossiersDialogService extends DialogService<DialogType> {
component: ManualAnnotationDialogComponent,
dialogConfig: { autoFocus: true },
},
highlightAction: {
component: HighlightActionDialogComponent,
},
};
constructor(protected readonly _dialog: MatDialog) {

View File

@ -3,7 +3,7 @@ import { forkJoin, Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { FileDataModel } from '@models/file/file-data.model';
import { PermissionsService } from '@services/permissions.service';
import { Dictionary, File, IRedactionLog, IViewedPage } from '@red/domain';
import { Dictionary, File, IRedactionLog, IViewedPage, TextHighlightResponse } from '@red/domain';
import { RedactionLogService } from './redaction-log.service';
import { ViewedPagesService } from '@services/entity-services/viewed-pages.service';
import { UserPreferenceService } from '@services/user-preference.service';
@ -11,12 +11,14 @@ import { FilePreviewStateService } from '../screens/file-preview-screen/services
import { Toaster } from '@iqser/common-ui';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { DictionariesMapService } from '@services/entity-services/dictionaries-map.service';
import { TextHighlightService } from './text-highlight.service';
@Injectable()
export class PdfViewerDataService {
constructor(
private readonly _permissionsService: PermissionsService,
private readonly _redactionLogService: RedactionLogService,
private readonly _textHighlightService: TextHighlightService,
private readonly _viewedPagesService: ViewedPagesService,
private readonly _userPreferenceService: UserPreferenceService,
private readonly _stateService: FilePreviewStateService,
@ -31,6 +33,10 @@ export class PdfViewerDataService {
);
}
loadTextHighlightsFor(dossierId: string, fileId: string): Observable<TextHighlightResponse> {
return this._textHighlightService.getTextHighlights(dossierId, fileId).pipe(catchError(() => of({})));
}
loadDataFor(newFile: File): Observable<FileDataModel> {
const redactionLog$ = this.loadRedactionLogFor(newFile.dossierId, newFile.fileId);
const viewedPages$ = this.getViewedPagesFor(newFile);

View File

@ -0,0 +1,40 @@
import { Injectable, Injector } from '@angular/core';
import { GenericService, RequiredParam, Toaster, Validate } from '@iqser/common-ui';
import { TextHighlightOperation, TextHighlightRequest, TextHighlightResponse } from '@red/domain';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { tap } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class TextHighlightService extends GenericService<unknown> {
constructor(protected readonly _injector: Injector, private readonly _toaster: Toaster) {
super(_injector, '');
}
@Validate()
getTextHighlights(@RequiredParam() dossierId: string, @RequiredParam() fileId: string) {
const request: TextHighlightRequest = {
dossierId,
fileId,
operation: TextHighlightOperation.INFO,
};
return this._post<TextHighlightResponse>(request, 'texthighlights-conversion');
}
@Validate()
performHighlightsAction(
@RequiredParam() dossierId: string,
@RequiredParam() fileId: string,
@RequiredParam() colors: string[],
@RequiredParam() operation: TextHighlightOperation,
) {
const request: TextHighlightRequest = { dossierId, fileId, colors, operation };
return this._post<TextHighlightResponse>(request, 'texthighlights-conversion').pipe(
tap(() => {
this._toaster.success(_('highlight-action-dialog.success'), { params: { operation } });
}),
);
}
}

View File

@ -6,5 +6,5 @@
[class.request]="isRequest"
class="icon"
>
<span>{{ label || dictionary.label.charAt(0) }}</span>
<span>{{ label || dictionary?.label?.charAt(0) }}</span>
</div>

View File

@ -60,10 +60,13 @@
<div *ngIf="filter.id === 'comment'">
<mat-icon svgIcon="red:comment"></mat-icon>
</div>
<redaction-annotation-icon *ngIf="filter.color" [color]="filter.color" [label]="''" type="square"></redaction-annotation-icon>
</ng-container>
<ng-container *ngIf="filter.icon">
<mat-icon [svgIcon]="filter.icon"></mat-icon>
</ng-container>
{{ filter.label | translate }}
<ng-container *ngIf="filter.skipTranslation; else translate"> {{ filter.label }}</ng-container>
<ng-template #translate>{{ filter.label | translate }} </ng-template>

View File

@ -1,7 +1,8 @@
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { AnnotationSuperType } from '../models/file/annotation.wrapper';
import { AnnotationSuperType } from '@models/file/annotation.wrapper';
export const annotationTypesTranslations: { [key in AnnotationSuperType]: string } = {
'text-highlight': _('annotation-type.text-highlight'),
'declined-suggestion': _('annotation-type.declined-suggestion'),
hint: _('annotation-type.hint'),
'ignored-hint': _('annotation-type.ignored-hint'),

View File

@ -1,6 +1,7 @@
import { AnnotationSuperType } from '../../models/file/annotation.wrapper';
export const SuperTypeSorter: { [key in AnnotationSuperType]: number } = {
'text-highlight': 100,
'suggestion-change-legal-basis': 14,
'suggestion-force-redaction': 15,
'suggestion-force-hint': 16,

View File

@ -310,7 +310,8 @@
"suggestion-recategorize-image": "Suggested recategorize image",
"suggestion-remove": "Suggested local removal",
"suggestion-remove-dictionary": "Suggested dictionary removal",
"suggestion-resize": "Suggested Resize"
"suggestion-resize": "Suggested Resize",
"text-highlight": "Highlight"
},
"annotations": "Annotations",
"assign-dossier-owner": {
@ -393,6 +394,7 @@
},
"header": "Edit Redaction Reason"
},
"color": "Color",
"comments": {
"add-comment": "Enter comment",
"comments": "{count} {count, plural, one{comment} other{comments}}",
@ -1207,6 +1209,10 @@
"exclude-pages": "Exclude pages from redaction",
"excluded-from-redaction": "excluded",
"fullscreen": "Full Screen (F)",
"highlights": {
"convert": "Convert highlights",
"remove": "Remove highlights"
},
"last-reviewer": "Last Reviewed by:",
"no-data": {
"title": "There have been no changes to this page."
@ -1254,8 +1260,13 @@
"put-back": "Undo",
"removed-from-redaction": "Removed from redaction"
},
"highlights": {
"label": "Highlights"
},
"is-excluded": "Redaction is disabled for this document."
},
"text-highlights": "Highlights",
"text-highlights-tooltip": "Shows all text-highlights and allows removing or importing them as redactions",
"toggle-analysis": {
"disable": "Disable redaction",
"enable": "Enable for redaction",
@ -1363,6 +1374,30 @@
"text": "Help Mode",
"welcome-to-help-mode": "<b> Welcome to Help Mode! <br> Clicking on interactive elements will open info about them in new tab. </b>"
},
"highlight-action-dialog": {
"actions": {
"cancel": "Cancel"
},
"convert": {
"confirmation": "All highlights in the document will be converted",
"details": "All highlights from the document will be converted to Imported Redactions, using the color set up in the Default Colors section of the app.",
"save": "Convert Highlights",
"title": "Convert highlights to imported redactions"
},
"form": {
"color": {
"label": "Highlight HEX Color"
}
},
"remove": {
"confirmation": "All highlights in this HEX Color will be removed from the document",
"details": "Removing highlights from the document will delete all the rectangles and leave a white background behind the highlighted text.",
"save": "Remove Highlights",
"title": "Remove highlights"
},
"success": "{operation, select, CONVERT{Converting} REMOVE{Removing} other{}} highlights in progress..."
},
"highlights": "{color} - {length} {length, plural, one{highlight} other{highlights}}",
"hint": "Hint",
"image-category": {
"formula": "Formula",
@ -1697,6 +1732,7 @@
"placeholder": "Search documents...",
"this-dossier": "in this dossier"
},
"size": "Size",
"smtp-auth-config": {
"actions": {
"cancel": "Cancel",

View File

@ -2,13 +2,13 @@
@use 'common-mixins';
.NEW {
stroke: variables.$grey-5;
background-color: variables.$grey-5;
stroke: var(--iqser-grey-5);
background-color: var(--iqser-grey-5);
}
.UNPROCESSED {
stroke: variables.$grey-3;
background-color: variables.$grey-3;
stroke: var(--iqser-grey-3);
background-color: var(--iqser-grey-3);
}
.UNDER_REVIEW,
@ -70,8 +70,8 @@
}
.INACTIVE {
stroke: variables.$grey-5;
background-color: variables.$grey-5;
stroke: var(--iqser-grey-5);
background-color: var(--iqser-grey-5);
}
.MANAGER,
@ -79,3 +79,22 @@
stroke: variables.$primary;
background-color: variables.$primary;
}
.workload-separator {
border-bottom: 1px solid var(--iqser-separator);
height: 32px;
box-sizing: border-box;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--iqser-grey-6);
> div {
display: flex;
> div:not(:last-child) {
margin-right: 8px;
}
}
}

@ -1 +1 @@
Subproject commit dd87dd6821ecc8d591b03191964e77fb5fc6e8f8
Subproject commit 17008f44757a29ae3d09581a94520c2a05670432

View File

@ -20,3 +20,4 @@ export * from './lib/signature';
export * from './lib/legal-basis';
export * from './lib/dossier-stats';
export * from './lib/dossier-state';
export * from './lib/text-highlight';

View File

@ -40,6 +40,7 @@ export class File extends Entity<IFile> implements IFile {
readonly hasSuggestions: boolean;
readonly processingStatus: ProcessingFileStatus;
readonly workflowStatus: WorkflowFileStatus;
readonly fileManipulationDate: string;
readonly statusSort: number;
readonly cacheIdentifier?: string;
@ -96,9 +97,10 @@ export class File extends Entity<IFile> implements IFile {
this.uploader = file.uploader;
this.excludedPages = file.excludedPages || [];
this.hasSuggestions = !!file.hasSuggestions;
this.fileManipulationDate = file.fileManipulationDate;
this.statusSort = StatusSorter[this.workflowStatus];
this.cacheIdentifier = btoa((this.lastUploaded ?? '') + (this.lastOCRTime ?? ''));
this.cacheIdentifier = btoa(this.fileManipulationDate ?? '');
this.hintsOnly = this.hasHints && !this.hasRedactions;
this.hasNone = !this.hasRedactions && !this.hasHints && !this.hasSuggestions;
this.isProcessing = isProcessingStatuses.includes(this.processingStatus);

View File

@ -147,4 +147,9 @@ export interface IFile {
readonly processingStatus: ProcessingFileStatus;
readonly workflowStatus: WorkflowFileStatus;
/**
* Last time the actual file was touched
*/
readonly fileManipulationDate: string;
}

View File

@ -1 +1 @@
export type ViewMode = 'STANDARD' | 'DELTA' | 'REDACTED';
export type ViewMode = 'STANDARD' | 'DELTA' | 'REDACTED' | 'TEXT_HIGHLIGHTS';

View File

@ -0,0 +1,6 @@
import { IRectangle } from '../geometry/rectangle';
export interface ImportedRedaction {
id: string;
positions: IRectangle[];
}

View File

@ -0,0 +1,4 @@
export * from './imported-redaction';
export * from './text-highlight-operation';
export * from './text-highlight.response';
export * from './text-highlight.request';

View File

@ -0,0 +1,5 @@
export enum TextHighlightOperation {
REMOVE = 'REMOVE',
CONVERT = 'CONVERT',
INFO = 'INFO',
}

View File

@ -0,0 +1,8 @@
import { TextHighlightOperation } from './text-highlight-operation';
export interface TextHighlightRequest {
dossierId: string;
fileId: string;
operation: TextHighlightOperation;
colors?: string[];
}

View File

@ -0,0 +1,9 @@
import { ImportedRedaction } from './imported-redaction';
import { TextHighlightOperation } from './text-highlight-operation';
export interface TextHighlightResponse {
dossierId?: string;
fileId?: string;
operation?: TextHighlightOperation;
redactionPerColor?: { [key: string]: ImportedRedaction[] };
}