RED-5908: update viewed pages when a change occurs

This commit is contained in:
Dan Percic 2023-01-09 21:58:28 +02:00
parent 341ec38841
commit e69bb94f8f
15 changed files with 159 additions and 104 deletions

View File

@ -86,16 +86,12 @@
<mat-icon svgIcon="red:nav-first"></mat-icon> <mat-icon svgIcon="red:nav-first"></mat-icon>
</div> </div>
<div class="pages" id="pages"> <redaction-pages
<redaction-page-indicator (click)="pagesPanelActive = true"
(pageSelected)="pageSelectedByClick($event)" [activePage]="activeViewerPage"
*ngFor="let pageNumber of displayedPages" [displayedAnnotations]="displayedAnnotations"
[activeSelection]="pageHasSelection(pageNumber)" [pages]="displayedPages"
[active]="pageNumber === activeViewerPage" ></redaction-pages>
[number]="pageNumber"
[showDottedIcon]="hasOnlyManualRedactionsAndIsExcluded(pageNumber)"
></redaction-page-indicator>
</div>
<div <div
(click)="scrollQuickNavLast()" (click)="scrollQuickNavLast()"

View File

@ -77,12 +77,6 @@
} }
} }
} }
.pages {
@include common-mixins.no-scroll-bar;
overflow: auto;
flex: 1;
}
} }
.annotations { .annotations {

View File

@ -185,15 +185,6 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnDestroy
} }
} }
hasOnlyManualRedactionsAndIsExcluded(pageNumber: number): boolean {
const hasOnlyManualRedactions = this.displayedAnnotations.get(pageNumber)?.every(annotation => annotation.manual);
return hasOnlyManualRedactions && this.file.excludedPages.includes(pageNumber);
}
pageHasSelection(page: number) {
return this.multiSelectService.isActive && !!this.listingService.selected.find(a => a.pageNumber === page);
}
selectAllOnActivePage() { selectAllOnActivePage() {
this.listingService.selectAnnotations(this.activeAnnotations); this.listingService.selectAnnotations(this.activeAnnotations);
} }
@ -286,11 +277,6 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnDestroy
return firstValueFrom(this.state.file$).then(file => this.pdf.navigateTo(file.numberOfPages)); return firstValueFrom(this.state.file$).then(file => this.pdf.navigateTo(file.numberOfPages));
} }
pageSelectedByClick($event: number): void {
this.pagesPanelActive = true;
this.pdf.navigateTo($event);
}
preventKeyDefault($event: KeyboardEvent): void { preventKeyDefault($event: KeyboardEvent): void {
if (COMMAND_KEY_ARRAY.includes($event.key) && !(($event.target as any).localName === 'input')) { if (COMMAND_KEY_ARRAY.includes($event.key) && !(($event.target as any).localName === 'input')) {
$event.preventDefault(); $event.preventDefault();

View File

@ -1,59 +1,45 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'; import { ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core';
import { PermissionsService } from '@services/permissions.service'; import { PermissionsService } from '@services/permissions.service';
import { ConfigService } from '@services/config.service';
import { ViewedPagesService } from '@services/files/viewed-pages.service'; import { ViewedPagesService } from '@services/files/viewed-pages.service';
import { FilePreviewStateService } from '../../services/file-preview-state.service'; import { FilePreviewStateService } from '../../services/file-preview-state.service';
import { PageRotationService } from '../../../pdf-viewer/services/page-rotation.service'; import { PageRotationService } from '../../../pdf-viewer/services/page-rotation.service';
import { ContextComponent } from '@iqser/common-ui'; import { ContextComponent, getConfig } from '@iqser/common-ui';
import { tap } from 'rxjs/operators'; import { tap } from 'rxjs/operators';
import { FileDataService } from '../../services/file-data.service'; import { AppConfig, ViewedPage } from '@red/domain';
import { ViewedPagesMapService } from '@services/files/viewed-pages-map.service';
interface PageIndicatorContext { interface PageIndicatorContext {
isRotated: boolean; isRotated: boolean;
} }
@Component({ @Component({
selector: 'redaction-page-indicator', selector: 'redaction-page-indicator [number] [read]',
templateUrl: './page-indicator.component.html', templateUrl: './page-indicator.component.html',
styleUrls: ['./page-indicator.component.scss'], styleUrls: ['./page-indicator.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class PageIndicatorComponent extends ContextComponent<PageIndicatorContext> implements OnChanges, OnInit { export class PageIndicatorComponent extends ContextComponent<PageIndicatorContext> implements OnChanges, OnInit {
@Input() active = false; @Input() active = false;
@Input() showDottedIcon = false; @Input() showDottedIcon = false;
@Input() number: number; @Input() number: number;
@Input() activeSelection = false; @Input() activeSelection = false;
@Input() read = false;
@Output() readonly pageSelected = new EventEmitter<number>(); @Output() readonly pageSelected = new EventEmitter<number>();
pageReadTimeout: number = null; pageReadTimeout: number = null;
read = false;
isRotated = false; isRotated = false;
readonly #config = getConfig<AppConfig>();
constructor( constructor(
private readonly _viewedPagesService: ViewedPagesService, private readonly _viewedPagesService: ViewedPagesService,
private readonly _configService: ConfigService, private readonly _viewedPagesMapService: ViewedPagesMapService,
private readonly _changeDetectorRef: ChangeDetectorRef, private readonly _changeDetectorRef: ChangeDetectorRef,
private readonly _permissionService: PermissionsService, private readonly _permissionService: PermissionsService,
private readonly _stateService: FilePreviewStateService, private readonly _state: FilePreviewStateService,
private readonly _fileDataService: FileDataService,
readonly pageRotationService: PageRotationService, readonly pageRotationService: PageRotationService,
) { ) {
super(); super();
} }
get activePage() {
return this._fileDataService.viewedPages.find(p => p.page === this.number);
}
get dossierId() {
return this._stateService.dossierId;
}
get fileId() {
return this._stateService.fileId;
}
ngOnInit() { ngOnInit() {
const isRotated$ = this.pageRotationService.isRotated$(this.number).pipe( const isRotated$ = this.pageRotationService.isRotated$(this.number).pipe(
tap(value => { tap(value => {
@ -65,24 +51,21 @@ export class PageIndicatorComponent extends ContextComponent<PageIndicatorContex
} }
ngOnChanges() { ngOnChanges() {
this._setReadState();
this.handlePageRead(); this.handlePageRead();
} }
async toggleReadState() { async toggleReadState() {
const file = this._stateService.file; if (this._permissionService.canMarkPagesAsViewed(this._state.file)) {
if (this._permissionService.canMarkPagesAsViewed(file)) {
if (this.read) { if (this.read) {
await this._markPageUnread(); await this.#markPageUnread();
} else { } else {
await this._markPageRead(); await this.#markPageRead();
} }
} }
} }
handlePageRead(): void { handlePageRead(): void {
const file = this._stateService.file; if (!this._permissionService.canMarkPagesAsViewed(this._state.file)) {
if (!this._permissionService.canMarkPagesAsViewed(file)) {
return; return;
} }
@ -93,36 +76,22 @@ export class PageIndicatorComponent extends ContextComponent<PageIndicatorContex
if (this.active && !this.read) { if (this.active && !this.read) {
this.pageReadTimeout = window.setTimeout(async () => { this.pageReadTimeout = window.setTimeout(async () => {
if (this.active && !this.read) { if (this.active && !this.read) {
await this._markPageRead(); await this.#markPageRead();
} }
}, this._configService.values.AUTO_READ_TIME * 1000); }, this.#config.AUTO_READ_TIME * 1000);
} }
} }
private _setReadState() { async #markPageRead() {
const activePage = this.activePage; const fileId = this._state.fileId;
if (!activePage) { await this._viewedPagesService.add({ page: this.number }, this._state.dossierId, fileId);
this.read = false; const viewedPage = new ViewedPage({ page: this.number, fileId });
} else { this._viewedPagesMapService.add(fileId, viewedPage);
this.read = !activePage.showAsUnseen;
}
this._changeDetectorRef.markForCheck();
} }
private async _markPageRead() { async #markPageUnread() {
await this._viewedPagesService.addPage({ page: this.number }, this.dossierId, this.fileId); const fileId = this._state.fileId;
if (this.activePage) { await this._viewedPagesService.remove(this._state.dossierId, fileId, this.number);
this.activePage.showAsUnseen = false; this._viewedPagesMapService.delete(fileId, this.number);
} else {
this._fileDataService.viewedPages.push({ page: this.number, fileId: this.fileId });
}
this._setReadState();
}
private async _markPageUnread() {
await this._viewedPagesService.removePage(this.dossierId, this.fileId, this.number);
const pageToDelete = this._fileDataService.viewedPages.findIndex(p => p.page === this.number);
this._fileDataService.viewedPages.splice(pageToDelete, 1);
this._setReadState();
} }
} }

View File

@ -0,0 +1,11 @@
<div *ngIf="viewedPages$ | async as viewedPages" class="pages" id="pages">
<redaction-page-indicator
(pageSelected)="pageSelectedByClick($event)"
*ngFor="let pageNumber of pages; trackBy: trackBy"
[activeSelection]="pageHasSelection(pageNumber)"
[active]="pageNumber === activePage"
[number]="pageNumber"
[read]="!!getViewedPage(viewedPages, pageNumber)"
[showDottedIcon]="hasOnlyManualRedactionsAndIsExcluded(pageNumber)"
></redaction-page-indicator>
</div>

View File

@ -0,0 +1,11 @@
@use 'common-mixins';
:host {
display: contents;
}
.pages {
@include common-mixins.no-scroll-bar;
overflow: auto;
flex: 1;
}

View File

@ -0,0 +1,46 @@
import { ChangeDetectionStrategy, Component, inject, Input } from '@angular/core';
import { List } from '@iqser/common-ui';
import { PdfViewer } from '../../../pdf-viewer/services/pdf-viewer.service';
import { MultiSelectService } from '../../services/multi-select.service';
import { AnnotationsListingService } from '../../services/annotations-listing.service';
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
import { FilePreviewStateService } from '../../services/file-preview-state.service';
import { ViewedPagesMapService } from '@services/files/viewed-pages-map.service';
import { ViewedPage } from '@red/domain';
@Component({
selector: 'redaction-pages [pages] [activePage] [displayedAnnotations]',
templateUrl: './pages.component.html',
styleUrls: ['./pages.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PagesComponent {
@Input() pages: List<number>;
@Input() activePage: number;
@Input() displayedAnnotations: Map<number, AnnotationWrapper[]>;
readonly #pdf = inject(PdfViewer);
readonly #state = inject(FilePreviewStateService);
readonly viewedPages$ = inject(ViewedPagesMapService).get$(this.#state.fileId);
readonly #multiSelectService = inject(MultiSelectService);
readonly #listingService = inject(AnnotationsListingService);
pageSelectedByClick($event: number): void {
this.#pdf.navigateTo($event);
}
readonly trackBy = (_index: number, item: number) => item;
pageHasSelection(page: number) {
return this.#multiSelectService.isActive && !!this.#listingService.selected.find(a => a.pageNumber === page);
}
hasOnlyManualRedactionsAndIsExcluded(pageNumber: number): boolean {
const hasOnlyManualRedactions = this.displayedAnnotations.get(pageNumber)?.every(annotation => annotation.manual);
return hasOnlyManualRedactions && this.#state.file.excludedPages.includes(pageNumber);
}
getViewedPage(viewedPages: ViewedPage[], pageNumber: number) {
return viewedPages.find(p => p.page === pageNumber);
}
}

View File

@ -54,6 +54,7 @@ import { FilePreviewRightContainerComponent } from './components/right-container
import { RssDialogComponent } from './dialogs/rss-dialog/rss-dialog.component'; import { RssDialogComponent } from './dialogs/rss-dialog/rss-dialog.component';
import { ReadonlyBannerComponent } from './components/readonly-banner/readonly-banner.component'; import { ReadonlyBannerComponent } from './components/readonly-banner/readonly-banner.component';
import { SuggestionsService } from './services/suggestions.service'; import { SuggestionsService } from './services/suggestions.service';
import { PagesComponent } from './components/pages/pages.component';
const routes: Routes = [ const routes: Routes = [
{ {
@ -85,6 +86,7 @@ const components = [
AnnotationWrapperComponent, AnnotationWrapperComponent,
AnnotationsListComponent, AnnotationsListComponent,
PageIndicatorComponent, PageIndicatorComponent,
PagesComponent,
PageExclusionComponent, PageExclusionComponent,
AnnotationActionsComponent, AnnotationActionsComponent,
CommentsComponent, CommentsComponent,

View File

@ -6,13 +6,15 @@ import { annotationTypesTranslations } from '@translations/annotation-types-tran
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { annotationDefaultColorConfig } from '@red/domain'; import { annotationDefaultColorConfig } from '@red/domain';
import { FilePreviewStateService } from './file-preview-state.service'; import { FilePreviewStateService } from './file-preview-state.service';
import { FileDataService } from './file-data.service';
import { DefaultColorsService } from '@services/entity-services/default-colors.service'; import { DefaultColorsService } from '@services/entity-services/default-colors.service';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { ViewedPagesMapService } from '@services/files/viewed-pages-map.service';
import { FileDataService } from './file-data.service';
@Injectable() @Injectable()
export class AnnotationProcessingService { export class AnnotationProcessingService {
constructor( constructor(
private readonly _viewedPagesMapService: ViewedPagesMapService,
private readonly _fileDataService: FileDataService, private readonly _fileDataService: FileDataService,
private readonly _state: FilePreviewStateService, private readonly _state: FilePreviewStateService,
private readonly _defaultColorsService: DefaultColorsService, private readonly _defaultColorsService: DefaultColorsService,
@ -43,7 +45,7 @@ export class AnnotationProcessingService {
checked: false, checked: false,
topLevelFilter: true, topLevelFilter: true,
checker: (annotation: AnnotationWrapper) => checker: (annotation: AnnotationWrapper) =>
!this._fileDataService.viewedPages.some(page => page.page === annotation.pageNumber), !this._viewedPagesMapService.get(this._state.fileId).some(page => page.page === annotation.pageNumber),
}, },
{ {
id: 'pages-without-annotations', id: 'pages-without-annotations',

View File

@ -1,4 +1,4 @@
import { ChangeType, File, IRedactionLog, IRedactionLogEntry, IViewedPage, ManualRedactionType, ViewMode, ViewModes } from '@red/domain'; import { ChangeType, File, IRedactionLog, IRedactionLogEntry, ManualRedactionType, ViewMode, ViewModes } from '@red/domain';
import { AnnotationWrapper } from '@models/file/annotation.wrapper'; import { AnnotationWrapper } from '@models/file/annotation.wrapper';
import { BehaviorSubject, firstValueFrom, iif, Observable, Subject } from 'rxjs'; import { BehaviorSubject, firstValueFrom, iif, Observable, Subject } from 'rxjs';
import { RedactionLogEntry } from '@models/file/redaction-log.entry'; import { RedactionLogEntry } from '@models/file/redaction-log.entry';
@ -20,12 +20,12 @@ import { FilesService } from '@services/files/files.service';
import { DefaultColorsService } from '@services/entity-services/default-colors.service'; import { DefaultColorsService } from '@services/entity-services/default-colors.service';
import { DictionaryService } from '@services/entity-services/dictionary.service'; import { DictionaryService } from '@services/entity-services/dictionary.service';
import { SuggestionsService } from './suggestions.service'; import { SuggestionsService } from './suggestions.service';
import { ViewedPagesMapService } from '@services/files/viewed-pages-map.service';
const DELTA_VIEW_TIME = 10 * 60 * 1000; // 10 minutes; const DELTA_VIEW_TIME = 10 * 60 * 1000; // 10 minutes;
@Injectable() @Injectable()
export class FileDataService extends EntitiesService<AnnotationWrapper, AnnotationWrapper> { export class FileDataService extends EntitiesService<AnnotationWrapper, AnnotationWrapper> {
viewedPages: IViewedPage[] = [];
missingTypes = new Set<string>(); missingTypes = new Set<string>();
readonly hasChangeLog$ = new BehaviorSubject<boolean>(false); readonly hasChangeLog$ = new BehaviorSubject<boolean>(false);
readonly annotations$: Observable<AnnotationWrapper[]>; readonly annotations$: Observable<AnnotationWrapper[]>;
@ -37,6 +37,7 @@ export class FileDataService extends EntitiesService<AnnotationWrapper, Annotati
constructor( constructor(
private readonly _state: FilePreviewStateService, private readonly _state: FilePreviewStateService,
private readonly _viewedPagesService: ViewedPagesService, private readonly _viewedPagesService: ViewedPagesService,
private readonly _viewedPagesMapService: ViewedPagesMapService,
private readonly _viewModeService: ViewModeService, private readonly _viewModeService: ViewModeService,
private readonly _userPreferenceService: UserPreferenceService, private readonly _userPreferenceService: UserPreferenceService,
private readonly _dictionaryService: DictionaryService, private readonly _dictionaryService: DictionaryService,
@ -148,11 +149,12 @@ export class FileDataService extends EntitiesService<AnnotationWrapper, Annotati
async #loadViewedPages(file: File) { async #loadViewedPages(file: File) {
if (!this._permissionsService.canMarkPagesAsViewed(file)) { if (!this._permissionsService.canMarkPagesAsViewed(file)) {
this.viewedPages = []; this._viewedPagesMapService.set(file.fileId, []);
return; return;
} }
this.viewedPages = await this._viewedPagesService.getViewedPages(file.dossierId, file.fileId); const viewedPages = await this._viewedPagesService.load(file.dossierId, file.fileId);
this._viewedPagesMapService.set(file.fileId, viewedPages);
} }
#getVisibleAnnotations(annotations: AnnotationWrapper[], viewMode: ViewMode) { #getVisibleAnnotations(annotations: AnnotationWrapper[], viewMode: ViewMode) {
@ -268,8 +270,7 @@ export class FileDataService extends EntitiesService<AnnotationWrapper, Annotati
const lastChange = viableChanges.length >= 1 ? viableChanges[viableChanges.length - 1] : undefined; const lastChange = viableChanges.length >= 1 ? viableChanges[viableChanges.length - 1] : undefined;
const page = redactionLogEntry.positions?.[0].page; const page = redactionLogEntry.positions?.[0].page;
const viewedPage = this._viewedPagesMapService.get(file.fileId, page);
const viewedPage = this.viewedPages.filter(p => p.page === page).pop();
// page has been seen -> let's see if it's a change // page has been seen -> let's see if it's a change
if (viewedPage) { if (viewedPage) {
@ -279,7 +280,11 @@ export class FileDataService extends EntitiesService<AnnotationWrapper, Annotati
// at least one unseen change // at least one unseen change
if (relevantChanges.length > 0) { if (relevantChanges.length > 0) {
// at least 1 relevant change // at least 1 relevant change
viewedPage.showAsUnseen = dayjs(viewedPage.viewedTime).valueOf() < dayjs(lastChange.dateTime).valueOf(); const showAsUnseen = dayjs(viewedPage.viewedTime).valueOf() < dayjs(lastChange.dateTime).valueOf();
if (showAsUnseen) {
this._viewedPagesMapService.delete(this._state.fileId, viewedPage);
}
this.hasChangeLog$.next(true); this.hasChangeLog$.next(true);
return { return {
changeLogType: relevantChanges[relevantChanges.length - 1].type, changeLogType: relevantChanges[relevantChanges.length - 1].type,

View File

@ -0,0 +1,8 @@
import { Injectable } from '@angular/core';
import { IViewedPage, ViewedPage } from '@red/domain';
import { EntitiesMapService } from '@iqser/common-ui';
@Injectable({ providedIn: 'root' })
export class ViewedPagesMapService extends EntitiesMapService<IViewedPage, ViewedPage> {
protected readonly _primaryKey = 'id';
}

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { GenericService, RequiredParam, Validate } from '@iqser/common-ui'; import { GenericService, mapEach, RequiredParam, Validate } from '@iqser/common-ui';
import { catchError, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { IViewedPage, IViewedPagesRequest } from '@red/domain'; import { IViewedPage, IViewedPagesRequest, ViewedPage } from '@red/domain';
import { firstValueFrom, of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
@Injectable({ @Injectable({
@ -11,22 +11,23 @@ export class ViewedPagesService extends GenericService<unknown> {
protected readonly _defaultModelPath = 'viewedPages'; protected readonly _defaultModelPath = 'viewedPages';
@Validate() @Validate()
addPage(@RequiredParam() body: IViewedPagesRequest, @RequiredParam() dossierId: string, @RequiredParam() fileId: string) { add(@RequiredParam() body: IViewedPagesRequest, @RequiredParam() dossierId: string, @RequiredParam() fileId: string) {
const modelPath = `${this._defaultModelPath}/${dossierId}/${fileId}`; const modelPath = `${this._defaultModelPath}/${dossierId}/${fileId}`;
return firstValueFrom(this._post(body, modelPath)); return firstValueFrom(this._post(body, modelPath));
} }
@Validate() @Validate()
removePage(@RequiredParam() dossierId: string, @RequiredParam() fileId: string, @RequiredParam() page: number) { remove(@RequiredParam() dossierId: string, @RequiredParam() fileId: string, @RequiredParam() page: number) {
const modelPath = `${this._defaultModelPath}/${dossierId}/${fileId}/${page}`; const modelPath = `${this._defaultModelPath}/${dossierId}/${fileId}/${page}`;
return firstValueFrom(super.delete({}, modelPath)); return firstValueFrom(super.delete({}, modelPath));
} }
@Validate() @Validate()
getViewedPages(@RequiredParam() dossierId: string, @RequiredParam() fileId: string) { load(@RequiredParam() dossierId: string, @RequiredParam() fileId: string) {
const request = this._getOne<{ pages?: IViewedPage[] }>([dossierId, fileId]).pipe( const request = this._getOne<{ pages?: IViewedPage[] }>([dossierId, fileId]).pipe(
map(res => res.pages), map(res => res.pages),
catchError(() => of([] as IViewedPage[])), catchError(() => of([] as IViewedPage[])),
mapEach(page => new ViewedPage(page)),
); );
return firstValueFrom(request); return firstValueFrom(request);

View File

@ -1,5 +1,6 @@
export * from './page-range'; export * from './page-range';
export * from './viewed-page'; export * from './viewed-page';
export * from './viewed-page.model';
export * from './viewed-pages.request'; export * from './viewed-pages.request';
export * from './page-exclusion.request'; export * from './page-exclusion.request';
export * from './page-rotation.request'; export * from './page-rotation.request';

View File

@ -0,0 +1,24 @@
import { Entity } from '@iqser/common-ui';
import { IViewedPage } from '.';
export class ViewedPage extends Entity<IViewedPage, number> {
readonly fileId: string;
readonly page: number;
readonly userId?: string;
readonly viewedTime?: string;
override readonly routerLink = undefined;
override readonly searchKey = '';
constructor(viewedPage: IViewedPage) {
super(viewedPage);
this.fileId = viewedPage.fileId;
this.page = viewedPage.page;
this.userId = viewedPage.userId;
this.viewedTime = viewedPage.viewedTime;
}
override get id() {
return this.page;
}
}

View File

@ -1,7 +1,6 @@
export interface IViewedPage { export interface IViewedPage {
fileId?: string; readonly fileId: string;
page?: number; readonly page: number;
userId?: string; readonly userId?: string;
viewedTime?: string; readonly viewedTime?: string;
showAsUnseen?: boolean;
} }