Merge pull request #1079 from mspalti/iiif-mirador

Angular support for IIIF and Mirador
This commit is contained in:
Tim Donohue
2021-10-22 12:17:42 -05:00
committed by GitHub
24 changed files with 1938 additions and 88 deletions

View File

@@ -19,6 +19,7 @@
"serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts",
"start:dev": "npm-run-all --parallel config:dev:watch serve",
"start:prod": "yarn run build:prod && yarn run serve:ssr",
"start:mirador:prod": "yarn run build:mirador && yarn run start:prod",
"analyze": "webpack-bundle-analyzer dist/browser/stats.json",
"build": "ng build",
"build:stats": "ng build --stats-json",
@@ -44,6 +45,7 @@
"clean": "yarn run clean:prod && yarn run clean:env && yarn run clean:node",
"clean:env": "rimraf src/environments/environment.ts",
"sync-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts",
"build:mirador": "webpack --config webpack/webpack.mirador.config.ts",
"merge-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts",
"postinstall": "ngcc",
"cypress:open": "cypress open",
@@ -106,6 +108,9 @@
"jsonschema": "1.4.0",
"jwt-decode": "^3.1.2",
"klaro": "^0.7.10",
"mirador": "^3.0.0",
"mirador-dl-plugin": "^0.13.0",
"mirador-share-plugin": "^0.10.0",
"moment": "^2.29.1",
"morgan": "^1.10.0",
"ng-mocks": "10.5.4",
@@ -118,6 +123,8 @@
"nouislider": "^14.6.3",
"pem": "1.14.4",
"postcss-cli": "^8.3.0",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^6.6.3",
"rxjs-spy": "^7.5.3",
@@ -157,6 +164,7 @@
"deep-freeze": "0.0.1",
"dotenv": "^8.2.0",
"fork-ts-checker-webpack-plugin": "^6.0.3",
"html-loader": "^1.3.2",
"html-webpack-plugin": "^4.5.0",
"http-proxy-middleware": "^1.0.5",
"jasmine-core": "^3.6.0",

View File

@@ -41,6 +41,8 @@ import { UIServerConfig } from './src/config/ui-server-config.interface';
* Set path for the browser application's dist folder
*/
const DIST_FOLDER = join(process.cwd(), 'dist/browser');
// Set path fir IIIF viewer.
const IIIF_VIEWER = join(process.cwd(), 'dist/iiif');
const indexHtml = existsSync(join(DIST_FOLDER, 'index.html')) ? 'index.html' : 'index';
@@ -135,6 +137,10 @@ export function app() {
* Serve static resources (images, i18n messages, …)
*/
server.get('*.*', cacheControl, express.static(DIST_FOLDER, { index: false }));
/*
* Fallthrough to the IIIF viewer (must be included in the build).
*/
server.use('/iiif', express.static(IIIF_VIEWER, {index:false}));
// Register the ngApp callback function to handle incoming requests
server.get('*', ngApp);

View File

@@ -28,6 +28,7 @@ import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-dat
import { TruncatableService } from '../../../../shared/truncatable/truncatable.service';
import { TruncatePipe } from '../../../../shared/utils/truncate.pipe';
import { JournalComponent } from './journal.component';
import { RouteService } from '../../../../core/services/route.service';
let comp: JournalComponent;
let fixture: ComponentFixture<JournalComponent>;
@@ -86,6 +87,7 @@ describe('JournalComponent', () => {
{ provide: NotificationsService, useValue: {} },
{ provide: DefaultChangeAnalyzer, useValue: {} },
{ provide: BitstreamDataService, useValue: mockBitstreamDataService },
{ provide: RouteService, useValue: {} }
],
schemas: [NO_ERRORS_SCHEMA]

View File

@@ -12,7 +12,6 @@ import { ItemPageAbstractFieldComponent } from './simple/field-components/specif
import { ItemPageUriFieldComponent } from './simple/field-components/specific-field/uri/item-page-uri-field.component';
import { ItemPageTitleFieldComponent } from './simple/field-components/specific-field/title/item-page-title-field.component';
import { ItemPageFieldComponent } from './simple/field-components/specific-field/item-page-field.component';
import { FileSectionComponent } from './simple/field-components/file-section/file-section.component';
import { CollectionsComponent } from './field-components/collections/collections.component';
import { FullItemPageComponent } from './full/full-item-page.component';
import { FullFileSectionComponent } from './full/field-components/file-section/full-file-section.component';
@@ -31,10 +30,12 @@ import { MediaViewerComponent } from './media-viewer/media-viewer.component';
import { MediaViewerVideoComponent } from './media-viewer/media-viewer-video/media-viewer-video.component';
import { MediaViewerImageComponent } from './media-viewer/media-viewer-image/media-viewer-image.component';
import { NgxGalleryModule } from '@kolkov/ngx-gallery';
import { MiradorViewerComponent } from './mirador-viewer/mirador-viewer.component';
import { VersionPageComponent } from './version-page/version-page/version-page.component';
import { VersionedItemComponent } from './simple/item-types/versioned-item/versioned-item.component';
import { ThemedFileSectionComponent} from './simple/field-components/file-section/themed-file-section.component';
const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator
PublicationComponent,
@@ -54,7 +55,6 @@ const DECLARATIONS = [
ItemPageUriFieldComponent,
ItemPageTitleFieldComponent,
ItemPageFieldComponent,
FileSectionComponent,
CollectionsComponent,
FullFileSectionComponent,
PublicationComponent,
@@ -65,6 +65,7 @@ const DECLARATIONS = [
MediaViewerComponent,
MediaViewerVideoComponent,
MediaViewerImageComponent,
MiradorViewerComponent,
VersionPageComponent,
];

View File

@@ -0,0 +1,4 @@
<p class="full-text-op">{{'iiifviewer.fullscreen.notice' | translate}}</p>
<p *ngIf="!isViewerAvailable" id="viewer-message">{{viewerMessage}}</p>
<iframe title="Mirador Viewer" allowtransparency="true" *ngIf="isViewerAvailable" [src]="iframeViewerUrl | async" id="mirador-viewer"></iframe>

View File

@@ -0,0 +1,13 @@
#mirador-viewer {
border: 1px solid #dee2e6;
height: 660px;
width: 100%
}
.full-text-op {
text-align: right;
color: #333333;
font-size: 0.8em;
}
p.full-text-op {
margin-bottom: 0;
}

View File

@@ -0,0 +1,255 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MiradorViewerComponent } from './mirador-viewer.component';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
import { createRelationshipsObservable } from '../simple/item-types/shared/item.component.spec';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { MetadataMap } from '../../core/shared/metadata.models';
import { Item } from '../../core/shared/item.model';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { createPaginatedList } from '../../shared/testing/utils.test';
import { of as observableOf } from 'rxjs';
import { MiradorViewerService } from './mirador-viewer.service';
import { HostWindowService } from '../../shared/host-window.service';
function getItem(metadata: MetadataMap) {
return Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])),
metadata: metadata,
relationships: createRelationshipsObservable()
});
}
const noMetadata = new MetadataMap();
const mockHostWindowService = {
// This isn't really testing mobile status, the return observable just allows the test to run.
widthCategory: observableOf(true),
};
describe('MiradorViewerComponent with search', () => {
let comp: MiradorViewerComponent;
let fixture: ComponentFixture<MiradorViewerComponent>;
const viewerService = jasmine.createSpyObj('MiradorViewerService', ['showEmbeddedViewer', 'getImageCount']);
beforeEach(waitForAsync(() => {
viewerService.showEmbeddedViewer.and.returnValue(true);
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock
}
})],
declarations: [MiradorViewerComponent],
providers: [
{ provide: BitstreamDataService, useValue: {} },
{ provide: HostWindowService, useValue: mockHostWindowService }
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(MiradorViewerComponent, {
set: {
providers: [
{ provide: MiradorViewerService, useValue: viewerService }
]
}
}).compileComponents();
}));
describe('searchable item', () => {
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(MiradorViewerComponent);
comp = fixture.componentInstance;
comp.object = getItem(noMetadata);
comp.searchable = true;
fixture.detectChanges();
}));
it('should set multi property to true', (() => {
expect(comp.multi).toBe(true);
}));
it('should set url "multi" param to true', (() => {
const value = fixture.debugElement
.nativeElement.querySelector('#mirador-viewer').src;
expect(value).toContain('multi=true');
}));
it('should set url "searchable" param to true', (() => {
const value = fixture.debugElement
.nativeElement.querySelector('#mirador-viewer').src;
expect(value).toContain('searchable=true');
}));
it('should not call mirador service image count', () => {
expect(viewerService.getImageCount).not.toHaveBeenCalled();
});
});
});
describe('MiradorViewerComponent with multiple images', () => {
let comp: MiradorViewerComponent;
let fixture: ComponentFixture<MiradorViewerComponent>;
const viewerService = jasmine.createSpyObj('MiradorViewerService', ['showEmbeddedViewer', 'getImageCount']);
beforeEach(waitForAsync(() => {
viewerService.showEmbeddedViewer.and.returnValue(true);
viewerService.getImageCount.and.returnValue(observableOf(2));
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock
}
})],
declarations: [MiradorViewerComponent],
providers: [
{ provide: BitstreamDataService, useValue: {} },
{ provide: HostWindowService, useValue: mockHostWindowService }
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(MiradorViewerComponent, {
set: {
providers: [
{ provide: MiradorViewerService, useValue: viewerService }
]
}
}).compileComponents();
}));
describe('non-searchable item with multiple images', () => {
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(MiradorViewerComponent);
comp = fixture.componentInstance;
comp.object = getItem(noMetadata);
comp.searchable = false;
fixture.detectChanges();
}));
it('should set url "multi" param to true', (() => {
const value = fixture.debugElement
.nativeElement.querySelector('#mirador-viewer').src;
expect(value).toContain('multi=true');
}));
it('should call mirador service image count', () => {
expect(viewerService.getImageCount).toHaveBeenCalled();
});
it('should omit "searchable" param from url', (() => {
const value = fixture.debugElement
.nativeElement.querySelector('#mirador-viewer').src;
expect(value).not.toContain('searchable=true');
}));
});
});
describe('MiradorViewerComponent with a single image', () => {
let comp: MiradorViewerComponent;
let fixture: ComponentFixture<MiradorViewerComponent>;
const viewerService = jasmine.createSpyObj('MiradorViewerService', ['showEmbeddedViewer', 'getImageCount']);
beforeEach(waitForAsync(() => {
viewerService.showEmbeddedViewer.and.returnValue(true);
viewerService.getImageCount.and.returnValue(observableOf(1));
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock
}
})],
declarations: [MiradorViewerComponent],
providers: [
{ provide: BitstreamDataService, useValue: {} },
{ provide: HostWindowService, useValue: mockHostWindowService }
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(MiradorViewerComponent, {
set: {
providers: [
{ provide: MiradorViewerService, useValue: viewerService }
]
}
}).compileComponents();
}));
describe('single image viewer', () => {
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(MiradorViewerComponent);
comp = fixture.componentInstance;
comp.object = getItem(noMetadata);
fixture.detectChanges();
}));
it('should omit "multi" param', (() => {
const value = fixture.debugElement
.nativeElement.querySelector('#mirador-viewer').src;
expect(value).not.toContain('multi=false');
}));
it('should call mirador service image count', () => {
expect(viewerService.getImageCount).toHaveBeenCalled();
});
});
});
describe('MiradorViewerComponent in development mode', () => {
let comp: MiradorViewerComponent;
let fixture: ComponentFixture<MiradorViewerComponent>;
const viewerService = jasmine.createSpyObj('MiradorViewerService', ['showEmbeddedViewer', 'getImageCount']);
beforeEach(waitForAsync(() => {
viewerService.showEmbeddedViewer.and.returnValue(false);
viewerService.getImageCount.and.returnValue(observableOf(1));
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock
}
})],
declarations: [MiradorViewerComponent],
providers: [
{ provide: BitstreamDataService, useValue: {} }
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(MiradorViewerComponent, {
set: {
providers: [
{ provide: MiradorViewerService, useValue: viewerService },
{ provide: HostWindowService, useValue: mockHostWindowService }
]
}
}).compileComponents();
}));
describe('embedded viewer', () => {
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(MiradorViewerComponent);
comp = fixture.componentInstance;
comp.object = getItem(noMetadata);
fixture.detectChanges();
}));
it('should not embed the viewer', (() => {
const value = fixture.debugElement
.nativeElement.querySelector('#mirador-viewer');
expect(value).toBeNull();
}));
it('should show message', (() => {
const value = fixture.debugElement
.nativeElement.querySelector('#viewer-message');
expect(value).toBeDefined();
}));
});
});

View File

@@ -0,0 +1,136 @@
import { ChangeDetectionStrategy, Component, Inject, Input, OnInit, PLATFORM_ID } from '@angular/core';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { Item } from '../../core/shared/item.model';
import { environment } from '../../../environments/environment';
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
import { Observable } from 'rxjs/internal/Observable';
import { map, take } from 'rxjs/operators';
import { of } from 'rxjs';
import { isPlatformBrowser } from '@angular/common';
import { MiradorViewerService } from './mirador-viewer.service';
import { HostWindowService, WidthCategory } from '../../shared/host-window.service';
@Component({
selector: 'ds-mirador-viewer',
styleUrls: ['./mirador-viewer.component.scss'],
templateUrl: './mirador-viewer.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ MiradorViewerService ]
})
export class MiradorViewerComponent implements OnInit {
@Input() object: Item;
/**
* A previous dspace search query.
*/
@Input() query: string;
/**
* True if searchable.
*/
@Input() searchable: boolean;
/**
* Hides embedded viewer in dev mode.
*/
isViewerAvailable = true;
/**
* The url for the iframe.
*/
iframeViewerUrl: Observable<SafeResourceUrl>;
/**
* Sets the viewer to show or hide thumbnail side navigation menu.
*/
multi = false;
/**
* Hides the thumbnail navigation menu on smaller viewports.
*/
notMobile = false;
viewerMessage = 'Sorry, the Mirador viewer is not currently available in development mode.';
constructor(private sanitizer: DomSanitizer,
private viewerService: MiradorViewerService,
private bitstreamDataService: BitstreamDataService,
private hostWindowService: HostWindowService,
@Inject(PLATFORM_ID) private platformId: any) {
}
/**
* Creates the url for the Mirador iframe. Adds parameters for the displaying the search panel, query results,
* or multi-page thumbnail navigation.
*/
setURL() {
// The path to the REST manifest endpoint.
const manifestApiEndpoint = encodeURIComponent(environment.rest.baseUrl + '/iiif/'
+ this.object.id + '/manifest');
// The Express path to Mirador viewer.
let viewerPath = '/iiif/mirador/index.html?manifest=' + manifestApiEndpoint;
if (this.searchable) {
// Tell the viewer add search to menu.
viewerPath += '&searchable=' + this.searchable;
}
if (this.query) {
// Tell the viewer to execute a search for the query term.
viewerPath += '&query=' + this.query;
}
if (this.multi) {
// Tell the viewer to add thumbnail navigation. If searchable, thumbnail navigation is added by default.
viewerPath += '&multi=' + this.multi;
}
if (this.notMobile) {
viewerPath += '&notMobile=true';
}
// TODO: Should the query term be trusted here?
return this.sanitizer.bypassSecurityTrustResourceUrl(viewerPath);
}
ngOnInit(): void {
/**
* Initializes the iframe url observable.
*/
if (isPlatformBrowser(this.platformId)) {
// Viewer is not currently available in dev mode so hide it in that case.
this.isViewerAvailable = this.viewerService.showEmbeddedViewer();
// The notMobile property affects the thumbnail navigation
// menu by hiding it for smaller viewports. This will not be
// responsive to resizing.
this.hostWindowService.widthCategory
.pipe(take(1))
.subscribe((category: WidthCategory) => {
this.notMobile = !(category === WidthCategory.XS || category === WidthCategory.SM);
});
// We need to set the multi property to true if the
// item is searchable or when the ORIGINAL bundle contains more
// than 1 image. (The multi property determines whether the
// Mirador side thumbnail navigation panel is shown.)
if (this.searchable) {
this.multi = true;
const observable = of('');
this.iframeViewerUrl = observable.pipe(
map((val) => {
return this.setURL();
})
);
} else {
// Sets the multi value based on the image count.
this.iframeViewerUrl = this.viewerService.getImageCount(this.object, this.bitstreamDataService).pipe(
map(c => {
if (c > 1) {
this.multi = true;
}
return this.setURL();
})
);
}
}
}
}

View File

@@ -0,0 +1,57 @@
import { Injectable, isDevMode } from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
import { Item } from '../../core/shared/item.model';
import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../core/shared/operators';
import { last, map, switchMap } from 'rxjs/operators';
import { RemoteData } from '../../core/data/remote-data';
import { PaginatedList } from '../../core/data/paginated-list.model';
import { Bitstream } from '../../core/shared/bitstream.model';
import { BitstreamFormat } from '../../core/shared/bitstream-format.model';
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
@Injectable()
export class MiradorViewerService {
LINKS_TO_FOLLOW: FollowLinkConfig<Bitstream>[] = [
followLink('format'),
];
/**
* Returns boolean to hide viewer when running in development mode.
* Needed until it's possible to embed the viewer in development builds.
*/
showEmbeddedViewer (): boolean {
return !isDevMode();
}
/**
* Returns observable of the number of images in the ORIGINAL bundle
* @param item
* @param bitstreamDataService
*/
getImageCount(item: Item, bitstreamDataService: BitstreamDataService): Observable<number> {
let count = 0;
return bitstreamDataService.findAllByItemAndBundleName(item, 'ORIGINAL', {
currentPage: 1,
elementsPerPage: 10
}, true, true, ...this.LINKS_TO_FOLLOW)
.pipe(
getFirstCompletedRemoteData(),
map((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => bitstreamsRD.payload),
map((paginatedList: PaginatedList<Bitstream>) => paginatedList.page),
switchMap((bitstreams: Bitstream[]) => bitstreams),
switchMap((bitstream: Bitstream) => bitstream.format.pipe(
getFirstSucceededRemoteDataPayload(),
map((format: BitstreamFormat) => format)
)),
map((format: BitstreamFormat) => {
if (format.mimetype.includes('image')) {
count++;
}
return count;
}),
last()
);
}
}

View File

@@ -1,3 +1,12 @@
<div class="row" *ngIf="iiifEnabled">
<div class="col-12">
<ds-mirador-viewer id="iiif-viewer"
[object]="object"
[searchable]="iiifSearchEnabled"
[query]="iiifQuery$ | async">
</ds-mirador-viewer>
</div>
</div>
<div class="d-flex flex-row">
<h2 class="item-page-title-field mr-auto">
{{'publication.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values>

View File

@@ -4,7 +4,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Store } from '@ngrx/store';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../../../core/cache/object-cache.service';
import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
@@ -25,15 +25,33 @@ import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-dat
import { TruncatableService } from '../../../../shared/truncatable/truncatable.service';
import { TruncatePipe } from '../../../../shared/utils/truncate.pipe';
import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component';
import { createRelationshipsObservable } from '../shared/item.component.spec';
import {
createRelationshipsObservable,
iiifEnabled,
iiifSearchEnabled, mockRouteService
} from '../shared/item.component.spec';
import { PublicationComponent } from './publication.component';
import { createPaginatedList } from '../../../../shared/testing/utils.test';
import { RouteService } from '../../../../core/services/route.service';
const mockItem: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])),
metadata: new MetadataMap(),
relationships: createRelationshipsObservable()
});
const iiifEnabledMap: MetadataMap = {
'dspace.iiif.enabled': [iiifEnabled],
};
const iiifEnabledWithSearchMap: MetadataMap = {
'dspace.iiif.enabled': [iiifEnabled],
'iiif.search.enabled': [iiifSearchEnabled],
};
const noMetadata = new MetadataMap();
function getItem(metadata: MetadataMap) {
return Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])),
metadata: metadata,
relationships: createRelationshipsObservable()
});
}
describe('PublicationComponent', () => {
let comp: PublicationComponent;
@@ -68,6 +86,7 @@ describe('PublicationComponent', () => {
{ provide: DSOChangeAnalyzer, useValue: {} },
{ provide: DefaultChangeAnalyzer, useValue: {} },
{ provide: BitstreamDataService, useValue: mockBitstreamDataService },
{ provide: RouteService, useValue: mockRouteService }
],
schemas: [NO_ERRORS_SCHEMA]
@@ -76,41 +95,81 @@ describe('PublicationComponent', () => {
}).compileComponents();
}));
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(PublicationComponent);
comp = fixture.componentInstance;
comp.object = mockItem;
fixture.detectChanges();
}));
describe('default view', () => {
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(PublicationComponent);
comp = fixture.componentInstance;
comp.object = getItem(noMetadata);
fixture.detectChanges();
}));
it('should contain a component to display the date', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-date-field'));
expect(fields.length).toBeGreaterThanOrEqual(1);
it('should contain a component to display the date', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-date-field'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
it('should not contain a metadata only author field', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-author-field'));
expect(fields.length).toBe(0);
});
it('should contain a mixed metadata and relationship field for authors', () => {
const fields = fixture.debugElement.queryAll(By.css('.ds-item-page-mixed-author-field'));
expect(fields.length).toBe(1);
});
it('should contain a component to display the abstract', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-abstract-field'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
it('should contain a component to display the uri', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-uri-field'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
it('should contain a component to display the collections', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-collections'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
});
it('should not contain a metadata only author field', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-author-field'));
expect(fields.length).toBe(0);
describe('with IIIF viewer', () => {
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(PublicationComponent);
comp = fixture.componentInstance;
comp.object = getItem(iiifEnabledMap);
fixture.detectChanges();
}));
it('should contain an iiif viewer component', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-mirador-viewer'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
});
it('should contain a mixed metadata and relationship field for authors', () => {
const fields = fixture.debugElement.queryAll(By.css('.ds-item-page-mixed-author-field'));
expect(fields.length).toBe(1);
});
describe('with IIIF viewer and search', () => {
it('should contain a component to display the abstract', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-abstract-field'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
beforeEach(waitForAsync(() => {
mockRouteService.getPreviousUrl.and.returnValue(of(['/search?q=bird&motivation=painting','/item']));
fixture = TestBed.createComponent(PublicationComponent);
comp = fixture.componentInstance;
comp.object = getItem(iiifEnabledWithSearchMap);
fixture.detectChanges();
}));
it('should contain a component to display the uri', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-uri-field'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
it('should contain an iiif viewer component', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-mirador-viewer'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
it('should call the RouteService getHistory method', () => {
expect(mockRouteService.getPreviousUrl).toHaveBeenCalled();
});
it('should contain a component to display the collections', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-collections'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,35 @@
import { Item } from '../../../../core/shared/item.model';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { RouteService } from '../../../../core/services/route.service';
export const isIiifEnabled = (item: Item) => {
return !!item.firstMetadataValue('dspace.iiif.enabled');
};
export const isIiifSearchEnabled = (item: Item) => {
return !!item.firstMetadataValue('iiif.search.enabled');
};
/**
* Checks to see if previous route was a dspace search. If
* it was, the search term is extracted and subsequently passed
* to the mirador viewer component.
* @param item the dspace object
* @param routeService
*/
export const getDSpaceQuery = (item: Item, routeService: RouteService): Observable<string> => {
return routeService.getPreviousUrl().pipe(
filter(r => {
return r.includes('/search');
}),
map(r => {
const arr = r.split('&');
const q = arr[1];
const v = q.split('=');
return v[1];
})
);
};

View File

@@ -30,6 +30,26 @@ import { GenericItemPageFieldComponent } from '../../field-components/specific-f
import { compareArraysUsing, compareArraysUsingIds } from './item-relationships-utils';
import { ItemComponent } from './item.component';
import { createPaginatedList } from '../../../../shared/testing/utils.test';
import { RouteService } from '../../../../core/services/route.service';
import { MetadataValue } from '../../../../core/shared/metadata.models';
export const iiifEnabled = Object.assign(new MetadataValue(),{
'value': 'true',
'language': null,
'authority': null,
'confidence': -1,
'place': 0
});
export const iiifSearchEnabled = Object.assign(new MetadataValue(), {
'value': 'true',
'language': null,
'authority': null,
'confidence': -1,
'place': 0
});
export const mockRouteService = jasmine.createSpyObj('RouteService', ['getPreviousUrl']);
/**
* Create a generic test for an item-page-fields component using a mockItem and the type of component
@@ -72,6 +92,7 @@ export function getItemPageFieldsTest(mockItem: Item, component) {
{ provide: NotificationsService, useValue: {} },
{ provide: DefaultChangeAnalyzer, useValue: {} },
{ provide: BitstreamDataService, useValue: mockBitstreamDataService },
{ provide: RouteService, useValue: {} }
],
schemas: [NO_ERRORS_SCHEMA]

View File

@@ -2,6 +2,9 @@ import { Component, Input, OnInit } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import { Item } from '../../../../core/shared/item.model';
import { getItemPageRoute } from '../../../item-page-routing-paths';
import { RouteService } from '../../../../core/services/route.service';
import { Observable } from 'rxjs';
import { getDSpaceQuery, isIiifEnabled, isIiifSearchEnabled } from './item-iiif-utils';
@Component({
selector: 'ds-item',
@@ -18,9 +21,33 @@ export class ItemComponent implements OnInit {
*/
itemPageRoute: string;
/**
* Enables the mirador component.
*/
iiifEnabled: boolean;
/**
* Used to configure search in mirador.
*/
iiifSearchEnabled: boolean;
/**
* The query term from the previous dspace search.
*/
iiifQuery$: Observable<string>;
mediaViewer = environment.mediaViewer;
constructor(protected routeService: RouteService) {
}
ngOnInit(): void {
this.itemPageRoute = getItemPageRoute(this.object);
// check to see if iiif viewer is required.
this.iiifEnabled = isIiifEnabled(this.object);
this.iiifSearchEnabled = isIiifSearchEnabled(this.object);
if (this.iiifSearchEnabled) {
this.iiifQuery$ = getDSpaceQuery(this.object, this.routeService);
}
}
}

View File

@@ -1,3 +1,12 @@
<div class="row" *ngIf="iiifEnabled">
<div class="col-12">
<ds-mirador-viewer id="iiif-viewer"
[object]="object"
[searchable]="iiifSearchEnabled"
[query]="iiifQuery$ | async">
</ds-mirador-viewer>
</div>
</div>
<div class="d-flex flex-row">
<h2 class="item-page-title-field mr-auto">
<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values>

View File

@@ -12,14 +12,12 @@ import { CommunityDataService } from '../../../../core/data/community-data.servi
import { DefaultChangeAnalyzer } from '../../../../core/data/default-change-analyzer.service';
import { DSOChangeAnalyzer } from '../../../../core/data/dso-change-analyzer.service';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { buildPaginatedList } from '../../../../core/data/paginated-list.model';
import { RelationshipService } from '../../../../core/data/relationship.service';
import { RemoteData } from '../../../../core/data/remote-data';
import { Bitstream } from '../../../../core/shared/bitstream.model';
import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service';
import { Item } from '../../../../core/shared/item.model';
import { MetadataMap } from '../../../../core/shared/metadata.models';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { UUIDService } from '../../../../core/shared/uuid.service';
import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
@@ -27,8 +25,15 @@ import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-dat
import { TruncatableService } from '../../../../shared/truncatable/truncatable.service';
import { TruncatePipe } from '../../../../shared/utils/truncate.pipe';
import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component';
import { createRelationshipsObservable } from '../shared/item.component.spec';
import {
createRelationshipsObservable,
iiifEnabled,
iiifSearchEnabled, mockRouteService
} from '../shared/item.component.spec';
import { UntypedItemComponent } from './untyped-item.component';
import { RouteService } from '../../../../core/services/route.service';
import { of } from 'rxjs';
import { createPaginatedList } from '../../../../shared/testing/utils.test';
import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service';
import { VersionDataService } from '../../../../core/data/version-data.service';
import { RouterTestingModule } from '@angular/router/testing';
@@ -36,11 +41,25 @@ import { WorkspaceitemDataService } from '../../../../core/submission/workspacei
import { SearchService } from '../../../../core/shared/search/search.service';
import { ItemVersionsSharedService } from '../../../../shared/item/item-versions/item-versions-shared.service';
const mockItem: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])),
metadata: new MetadataMap(),
relationships: createRelationshipsObservable()
});
const iiifEnabledMap: MetadataMap = {
'dspace.iiif.enabled': [iiifEnabled],
};
const iiifEnabledWithSearchMap: MetadataMap = {
'dspace.iiif.enabled': [iiifEnabled],
'iiif.search.enabled': [iiifSearchEnabled],
};
const noMetadata = new MetadataMap();
function getItem(metadata: MetadataMap) {
return Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])),
metadata: metadata,
relationships: createRelationshipsObservable()
});
}
describe('UntypedItemComponent', () => {
let comp: UntypedItemComponent;
@@ -84,48 +103,94 @@ describe('UntypedItemComponent', () => {
{ provide: SearchService, useValue: {} },
{ provide: ItemDataService, useValue: {} },
{ provide: ItemVersionsSharedService, useValue: {} },
{ provide: RouteService, useValue: mockRouteService }
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(UntypedItemComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(UntypedItemComponent);
comp = fixture.componentInstance;
comp.object = mockItem;
fixture.detectChanges();
}));
describe('default view', () => {
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(UntypedItemComponent);
comp = fixture.componentInstance;
comp.object = getItem(noMetadata);
fixture.detectChanges();
}));
it('should contain a component to display the date', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-date-field'));
expect(fields.length).toBeGreaterThanOrEqual(1);
it('should contain a component to display the date', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-date-field'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
it('should not contain a metadata only author field', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-author-field'));
expect(fields.length).toBe(0);
});
it('should contain a mixed metadata and relationship field for authors', () => {
const fields = fixture.debugElement.queryAll(By.css('.ds-item-page-mixed-author-field'));
expect(fields.length).toBe(1);
});
it('should contain a component to display the abstract', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-abstract-field'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
it('should contain a component to display the uri', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-uri-field'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
it('should contain a component to display the collections', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-collections'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
it('should not contain an iiif viewer component', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-mirador-viewer'));
expect(fields.length).toBe(0);
});
});
it('should not contain a metadata only author field', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-author-field'));
expect(fields.length).toBe(0);
describe('with IIIF viewer', () => {
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(UntypedItemComponent);
comp = fixture.componentInstance;
comp.object = getItem(iiifEnabledMap);
fixture.detectChanges();
}));
it('should contain an iiif viewer component', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-mirador-viewer'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
});
it('should contain a mixed metadata and relationship field for authors', () => {
const fields = fixture.debugElement.queryAll(By.css('.ds-item-page-mixed-author-field'));
expect(fields.length).toBe(1);
});
describe('with IIIF viewer and search', () => {
it('should contain a component to display the abstract', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-abstract-field'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
beforeEach(waitForAsync(() => {
mockRouteService.getPreviousUrl.and.returnValue(of(['/search?q=bird&motivation=painting','/item']));
fixture = TestBed.createComponent(UntypedItemComponent);
comp = fixture.componentInstance;
comp.object = getItem(iiifEnabledWithSearchMap);
fixture.detectChanges();
}));
it('should contain a component to display the uri', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-uri-field'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
it('should contain an iiif viewer component', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-mirador-viewer'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
it('should call the RouteService getHistory method', () => {
expect(mockRouteService.getPreviousUrl).toHaveBeenCalled();
});
it('should contain a component to display the collections', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-collections'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -11,13 +11,14 @@ import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-dat
import { buildPaginatedList } from '../../../../core/data/paginated-list.model';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { MetadataMap } from '../../../../core/shared/metadata.models';
import { createRelationshipsObservable } from '../shared/item.component.spec';
import { createRelationshipsObservable, mockRouteService } from '../shared/item.component.spec';
import { RouterTestingModule } from '@angular/router/testing';
import { Component } from '@angular/core';
import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service';
import { SearchService } from '../../../../core/shared/search/search.service';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { Version } from '../../../../core/shared/version.model';
import { RouteService } from '../../../../core/services/route.service';
const mockItem: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])),
@@ -66,6 +67,7 @@ describe('VersionedItemComponent', () => {
{ provide: WorkspaceitemDataService, useValue: {} },
{ provide: SearchService, useValue: {} },
{ provide: ItemDataService, useValue: {} },
{ provide: RouteService, useValue: mockRouteService }
]
}).compileComponents();
versionService = TestBed.inject(VersionDataService);

View File

@@ -16,6 +16,7 @@ import { SearchService } from '../../../../core/shared/search/search.service';
import { Item } from '../../../../core/shared/item.model';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { WorkspaceItem } from '../../../../core/submission/models/workspaceitem.model';
import { RouteService } from '../../../../core/services/route.service';
@Component({
selector: 'ds-versioned-item',
@@ -34,8 +35,9 @@ export class VersionedItemComponent extends ItemComponent {
private workspaceItemDataService: WorkspaceitemDataService,
private searchService: SearchService,
private itemService: ItemDataService,
protected routeService: RouteService
) {
super();
super(routeService);
}
/**

View File

@@ -187,6 +187,7 @@ import { MissingTranslationHelper } from './translate/missing-translation.helper
import { ItemVersionsNoticeComponent } from './item/item-versions/notice/item-versions-notice.component';
import { FileValidator } from './utils/require-file.validator';
import { FileValueAccessorDirective } from './utils/file-value-accessor.directive';
import { FileSectionComponent } from '../item-page/simple/field-components/file-section/file-section.component';
import { ExistingRelationListElementComponent } from './form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component';
import { ModifyItemOverviewComponent } from '../item-page/edit-item-page/modify-item-overview/modify-item-overview.component';
import { ClaimedTaskActionsLoaderComponent } from './mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component';
@@ -327,6 +328,7 @@ const COMPONENTS = [
DsDatePickerInlineComponent,
DsSelectComponent,
ErrorComponent,
FileSectionComponent,
FormComponent,
LangSwitchComponent,
LoadingComponent,

View File

@@ -2261,6 +2261,28 @@
"journalvolume.page.volume": "Volume",
"iiifsearchable.listelement.badge": "Document Media",
"iiifsearchable.page.titleprefix": "Document: ",
"iiifsearchable.page.doi": "Permanent Link: ",
"iiifsearchable.page.issue": "Issue: ",
"iiifsearchable.page.description": "Description: ",
"iiifviewer.fullscreen.notice": "Use full screen for better viewing.",
"iiif.listelement.badge": "Image Media",
"iiif.page.titleprefix": "Image: ",
"iiif.page.doi": "Permanent Link: ",
"iiif.page.issue": "Issue: ",
"iiif.page.description": "Description: ",
"loading.bitstream": "Loading bitstream...",

166
src/mirador-viewer/index.js Normal file
View File

@@ -0,0 +1,166 @@
import Mirador from 'mirador/dist/es/src/index';
import miradorShareDialogPlugin from 'mirador-share-plugin/es/MiradorShareDialog';
import miradorSharePlugin from 'mirador-share-plugin/es/miradorSharePlugin';
import miradorDownloadPlugin from 'mirador-dl-plugin/es/miradorDownloadPlugin';
import miradorDownloadDialog from 'mirador-dl-plugin/es/MiradorDownloadDialog';
const params = new URLSearchParams(location.search);
const manifest = params.get('manifest');
const searchOption = params.get('searchable');
const query = params.get('query');
const multi = params.get('multi');
const notMobile = params.get('notMobile');
let windowSettings = {};
let sidbarPanel = 'info';
let defaultView = 'single';
let multipleItems = false;
let thumbNavigation = 'off';
windowSettings.manifestId = manifest;
(() => {
if (searchOption) {
defaultView = 'book';
sidbarPanel = 'search';
multipleItems = true;
if (notMobile) {
thumbNavigation = 'far-right';
}
if (query !== 'null') {
windowSettings.defaultSearchQuery = query;
}
} else {
if(multi) {
multipleItems = multi;
if (notMobile) {
thumbNavigation = 'far-right';
}
}
}
})();
(Mirador.viewer(
{
id: 'mirador',
mainMenuSettings: {
show: true
},
thumbnailNavigation: {
defaultPosition: thumbNavigation, // Which position for the thumbnail navigation to be be displayed. Other possible values are "far-bottom" or "far-right"
displaySettings: true, // Display the settings for this in WindowTopMenu
height: 120, // height of entire ThumbnailNavigation area when position is "far-bottom"
width: 100, // width of one canvas (doubled for book view) in ThumbnailNavigation area when position is "far-right"
},
themes: {
light: {
palette: {
type: 'light',
primary: {
main: '#266883',
},
secondary: {
main: '#b03727',
},
shades: { // Shades that can be used to offset color areas of the Workspace / Window
dark: '#eeeeee',
main: '#ffffff',
light: '#ffffff',
},
highlights: {
primary: '#ffff00',
secondary: '#00BFFF',
},
search: {
default: { fillStyle: '#00BFFF', globalAlpha: 0.3 },
hovered: { fillStyle: '#00FFFF', globalAlpha: 0.3 },
selected: { fillStyle: '#ff0900', globalAlpha: 0.3 },
},
},
},
dark: {
palette: {
type: 'dark',
primary: {
main: '#2790b0',
},
secondary: {
main: '#eeeeee',
},
highlights: {
primary: '#ffff00',
secondary: '#00BFFF',
},
},
},
},
selectedTheme: 'light',
data: [manifest],
windows: [
windowSettings
],
miradorSharePlugin: {
dragAndDropInfoLink: 'https://iiif.io',
embedOption: {
enabled: true,
embedUrlReplacePattern: [
/.*\.edu\/(\w+)\/iiif\/manifest/,
manifest
],
syncIframeDimensions: {
height: {param: 'maxheight'},
},
},
shareLink: {
enabled: true,
manifestIdReplacePattern: [
/\/iiif\/manifest/,
'',
],
},
},
miradorDownloadPlugin: {
restrictDownloadOnSizeDefinition: false
},
window: {
allowClose: false,
// sideBarOpenByDefault: false,
allowFullscreen: true,
allowMaximize: false,
defaultView: defaultView,
sideBarOpen: notMobile,
allowTopMenuButton: true,
defaultSidebarPanelWidth: 230,
switchCanvasOnSearch: true,
views: [
{ key: 'single', behaviors: ['individuals'] },
{ key: 'book', behaviors: ['paged'] },
{ key: 'scroll', behaviors: ['continuous'] },
{ key: 'gallery' },
],
panels: {
info: true,
attribution: false,
canvas: true,
search: searchOption,
layers: false,
},
sideBarPanel: sidbarPanel
},
workspace: {
allowNewWindows: false,
showZoomControls: true,
type: 'mosaic'
},
workspaceControlPanel: {
enabled: false
}
},
[
miradorShareDialogPlugin,
miradorSharePlugin,
miradorDownloadDialog,
miradorDownloadPlugin
]
)
)(manifest);

View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Mirador</title>
</head>
<body>
<div id="mirador"></div>
</body>
</html>

View File

@@ -0,0 +1,28 @@
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
module.exports = {
mode: 'production',
entry: {
mirador: './src/mirador-viewer/index.js'
},
output: {
path: path.resolve(__dirname, '..' , 'dist/iiif/mirador'),
filename: '[name].js'
},
module: {
rules: [
{
test: /\.html$/i,
loader: 'html-loader',
},
],
},
devServer: {
contentBase: '../dist/iiif/mirador',
},
plugins: [new HtmlWebpackPlugin({
filename: 'index.html',
template: './src/mirador-viewer/mirador.html'
})]
};

939
yarn.lock

File diff suppressed because it is too large Load Diff