diff --git a/docs/releases/v2.2.0.md b/docs/releases/v2.2.0.md index c927a80ccc6ddf080cd1ebb26f9ca63e7015e0db..01cfb1dc21808d3cb19176ccbe834a8c65e176dd 100644 --- a/docs/releases/v2.2.0.md +++ b/docs/releases/v2.2.0.md @@ -9,3 +9,4 @@ - Fixed false positive CSP violations (#490) - Fixed `standAloneVolumes` only showing the first volume +- Fixed `pluginControl.loadExternalLibraries` and `pluginControl.unloadExternalLibraries` (#516) diff --git a/src/atlasViewer/atlasViewer.apiService.service.spec.ts b/src/atlasViewer/atlasViewer.apiService.service.spec.ts index 5260669fd60eb4e8d2bd69d9484c97239b0456b2..a2c77a18d54841ba51a0fbf8e752763317f972a5 100644 --- a/src/atlasViewer/atlasViewer.apiService.service.spec.ts +++ b/src/atlasViewer/atlasViewer.apiService.service.spec.ts @@ -5,7 +5,8 @@ import { defaultRootState } from "src/services/stateStore.service"; import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module"; import { HttpClientModule } from '@angular/common/http'; import { WidgetModule } from './widgetUnit/widget.module'; -import { PluginModule } from './pluginUnit/plugin.module'; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { PluginServices } from "./pluginUnit"; describe('atlasViewer.apiService.service.ts', () => { @@ -17,15 +18,17 @@ describe('atlasViewer.apiService.service.ts', () => { afterEach(() => { cancelTokenSpy.calls.reset() cancellableDialogSpy.calls.reset() + + const ctrl = TestBed.inject(HttpTestingController) + ctrl.verify() }) beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ AngularMaterialModule, - HttpClientModule, + HttpClientTestingModule, WidgetModule, - PluginModule, ], providers: [ AtlasViewerAPIServices, @@ -33,6 +36,10 @@ describe('atlasViewer.apiService.service.ts', () => { { provide: CANCELLABLE_DIALOG, useValue: cancellableDialogSpy + }, + { + provide: PluginServices, + useValue: {} } ] }).compileComponents() @@ -250,7 +257,6 @@ describe('atlasViewer.apiService.service.ts', () => { AngularMaterialModule, HttpClientModule, WidgetModule, - PluginModule, ], providers: [ { @@ -267,6 +273,10 @@ describe('atlasViewer.apiService.service.ts', () => { return mockGetMouseOverSegments } }, + { + provide: PluginServices, + useValue: {} + }, AtlasViewerAPIServices, provideMockStore({ initialState: defaultRootState }), ] diff --git a/src/atlasViewer/atlasViewer.constantService.service.ts b/src/atlasViewer/atlasViewer.constantService.service.ts index cf1f2d83e4b2fb03d8b42b8293ccf4af52392474..30cde5aded6877e7df1624f37333c1e17135d61b 100644 --- a/src/atlasViewer/atlasViewer.constantService.service.ts +++ b/src/atlasViewer/atlasViewer.constantService.service.ts @@ -338,13 +338,6 @@ Send us an email: <a target = "_blank" href = "mailto:${this.supportEmailAddress public dissmissUserLayerSnackbarMessage: string = this.dissmissUserLayerSnackbarMessageDesktop } -const parseURLToElement = (url: string): HTMLElement => { - const el = document.createElement('script') - el.setAttribute('crossorigin', 'true') - el.src = url - return el -} - export const UNSUPPORTED_PREVIEW = [{ text: 'Preview of Colin 27 and JuBrain Cytoarchitectonic', previewSrc: './res/image/1.png', @@ -358,16 +351,6 @@ export const UNSUPPORTED_PREVIEW = [{ export const UNSUPPORTED_INTERVAL = 7000 -export const SUPPORT_LIBRARY_MAP: Map<string, HTMLElement> = new Map([ - ['jquery@3', parseURLToElement('https://code.jquery.com/jquery-3.3.1.min.js')], - ['jquery@2', parseURLToElement('https://code.jquery.com/jquery-2.2.4.min.js')], - ['webcomponentsLite@1.1.0', parseURLToElement('https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.1.0/webcomponents-lite.js')], - ['react@16', parseURLToElement('https://unpkg.com/react@16/umd/react.development.js')], - ['reactdom@16', parseURLToElement('https://unpkg.com/react-dom@16/umd/react-dom.development.js')], - ['vue@2.5.16', parseURLToElement('https://cdn.jsdelivr.net/npm/vue@2.5.16/dist/vue.js')], - ['preact@8.4.2', parseURLToElement('https://cdn.jsdelivr.net/npm/preact@8.4.2/dist/preact.min.js')], - ['d3@5.7.0', parseURLToElement('https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js')], -]) /** * First attempt at encoding int (e.g. selected region, navigation location) from number (loc info density) to b64 (higher info density) diff --git a/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.ts b/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.ts index 22ed4cd0eeca8e72b4a66b0c67ee9fb1e387363d..aeecdfb1d35ccb00794f7e31c324e2c7362d279e 100644 --- a/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.ts +++ b/src/atlasViewer/pluginUnit/atlasViewer.pluginService.service.ts @@ -1,16 +1,25 @@ import { HttpClient } from '@angular/common/http' -import { ComponentFactory, ComponentFactoryResolver, Injectable, ViewContainerRef } from "@angular/core"; +import { ComponentFactory, ComponentFactoryResolver, Injectable, ViewContainerRef, Inject } from "@angular/core"; import { PLUGINSTORE_ACTION_TYPES } from "src/services/state/pluginState.store"; import { IavRootStoreInterface, isDefined } from 'src/services/stateStore.service' import { PluginUnit } from "./pluginUnit.component"; import { WidgetServices } from "../widgetUnit/widgetService.service"; import { select, Store } from "@ngrx/store"; -import { BehaviorSubject, merge, Observable, of } from "rxjs"; -import { filter, map, shareReplay } from "rxjs/operators"; +import { BehaviorSubject, merge, Observable, of, zip } from "rxjs"; +import { filter, map, shareReplay, switchMap, catchError } from "rxjs/operators"; import { LoggingService } from 'src/logging'; import { PluginHandler } from 'src/util/pluginHandler'; -import { AtlasViewerConstantsServices } from "../atlasViewer.constantService.service"; import { WidgetUnit } from "../widgetUnit/widgetUnit.component"; +import { APPEND_SCRIPT_TOKEN, REMOVE_SCRIPT_TOKEN, BACKENDURL, getHttpHeader } from 'src/util/constants'; +import { PluginFactoryDirective } from './pluginFactory.directive'; + +export const registerPluginFactoryDirectiveFactory = (pSer: PluginServices) => { + return (pFactoryDirective: PluginFactoryDirective) => { + pSer.loadExternalLibraries = pFactoryDirective.loadExternalLibraries.bind(pFactoryDirective) + pSer.unloadExternalLibraries = pFactoryDirective.unloadExternalLibraries.bind(pFactoryDirective) + pSer.pluginViewContainerRef = pFactoryDirective.viewContainerRef + } +} @Injectable({ providedIn : 'root', @@ -25,8 +34,7 @@ export class PluginServices { public fetchedPluginManifests: IPluginManifest[] = [] public pluginViewContainerRef: ViewContainerRef - public appendSrc: (script: HTMLElement) => void - public removeSrc: (script: HTMLElement) => void + private pluginUnitFactory: ComponentFactory<PluginUnit> public minimisedPlugins$: Observable<Set<string>> @@ -36,12 +44,13 @@ export class PluginServices { public fetch: (url: string, httpOption?: any) => Promise<any> = (url, httpOption = {}) => this.http.get(url, httpOption).toPromise() constructor( - private constantService: AtlasViewerConstantsServices, private widgetService: WidgetServices, private cfr: ComponentFactoryResolver, private store: Store<IavRootStoreInterface>, private http: HttpClient, private log: LoggingService, + @Inject(APPEND_SCRIPT_TOKEN) private appendSrc: (src: string) => Promise<HTMLScriptElement>, + @Inject(REMOVE_SCRIPT_TOKEN) private removeSrc: (src: HTMLScriptElement) => void, ) { // TODO implement @@ -56,46 +65,36 @@ export class PluginServices { /** * TODO convert to rxjs streams, instead of Promise.all */ - const promiseFetchedPluginManifests: Promise<IPluginManifest[]> = new Promise((resolve, reject) => { - Promise.all([ - // TODO convert to use this.fetch - PLUGINDEV - ? fetch(PLUGINDEV, this.constantService.getFetchOption()).then(res => res.json()) - : Promise.resolve([]), - new Promise(rs => { - fetch(`${this.constantService.backendUrl}plugins`, this.constantService.getFetchOption()) - .then(res => res.json()) - .then(arr => Promise.all( - arr.map(url => new Promise(rs2 => - /** - * instead of failing all promises when fetching manifests, only fail those that fails to fetch - */ - fetch(url, this.constantService.getFetchOption()).then(res => res.json()).then(rs2).catch(e => (this.log.log('fetching manifest error', e), rs2(null)))), - ), - )) - .then(manifests => rs( - manifests.filter(m => !!m), - )) - .catch(e => { - this.constantService.catchError(e) - rs([]) - }) - }), - Promise.all( - BUNDLEDPLUGINS - .filter(v => typeof v === 'string') - .map(v => fetch(`res/plugin_examples/${v}/manifest.json`, this.constantService.getFetchOption()).then(res => res.json())), + + const pluginUrl = `${BACKENDURL.replace(/\/$/,'')}/plugins` + const streamFetchedManifests$ = this.http.get(pluginUrl,{ + responseType: 'json', + headers: getHttpHeader(), + }).pipe( + switchMap((arr: string[]) => { + return zip( + ...arr.map(url => this.http.get(url, { + responseType: 'json', + headers: getHttpHeader() + }).pipe( + catchError((err, caught) => of(null)) + )) + ).pipe( + map(arr => arr.filter(v => !!v)) ) - .then(arr => arr.reduce((acc, curr) => acc.concat(curr) , [])), - ]) - .then(arr => resolve( [].concat(arr[0]).concat(arr[1]) )) - .catch(reject) - }) + }) + ) - promiseFetchedPluginManifests - .then(arr => - this.fetchedPluginManifests = arr) - .catch(this.log.error) + streamFetchedManifests$.subscribe( + arr => { + this.fetchedPluginManifests = arr + this.log.log(this.fetchedPluginManifests) + }, + this.log.error, + () => { + this.log.log(`fetching end`) + } + ) this.minimisedPlugins$ = merge( of(new Set()), @@ -191,7 +190,7 @@ export class PluginServices { this.addPluginToIsLaunchingSet(plugin.name) return this.readyPlugin(plugin) - .then(() => { + .then(async () => { const pluginUnit = this.pluginViewContainerRef.createComponent( this.pluginUnitFactory ) /* TODO in v0.2, I used: @@ -240,11 +239,8 @@ export class PluginServices { shutdownCB.push(cb) } - const script = document.createElement('script') - script.src = plugin.scriptURL - - this.appendSrc(script) - handler.onShutdown(() => this.removeSrc(script)) + const scriptEl = await this.appendSrc(plugin.scriptURL) + handler.onShutdown(() => this.removeSrc(scriptEl)) const template = document.createElement('div') template.insertAdjacentHTML('afterbegin', plugin.template) diff --git a/src/atlasViewer/pluginUnit/plugin.module.ts b/src/atlasViewer/pluginUnit/plugin.module.ts index 88198bfe685c2613faee04332ae417ffd6e9ee1c..12763521faa0539bdf7eb38d7b275eba8263dcb7 100644 --- a/src/atlasViewer/pluginUnit/plugin.module.ts +++ b/src/atlasViewer/pluginUnit/plugin.module.ts @@ -1,8 +1,10 @@ import { NgModule } from "@angular/core"; import { PluginUnit } from "./pluginUnit.component"; -import { PluginServices } from "./atlasViewer.pluginService.service"; -import { PluginFactoryDirective } from "./pluginFactory.directive"; +import { PluginServices, registerPluginFactoryDirectiveFactory } from "./atlasViewer.pluginService.service"; +import { PluginFactoryDirective, REGISTER_PLUGIN_FACTORY_DIRECTIVE } from "./pluginFactory.directive"; import { LoggingModule } from "src/logging"; +import { APPEND_SCRIPT_TOKEN, appendScriptFactory, REMOVE_SCRIPT_TOKEN, removeScriptFactory } from "src/util/constants"; +import { DOCUMENT } from "@angular/common"; @NgModule({ imports:[ @@ -20,7 +22,22 @@ import { LoggingModule } from "src/logging"; PluginFactoryDirective ], providers: [ - PluginServices + PluginServices, + { + provide: REGISTER_PLUGIN_FACTORY_DIRECTIVE, + useFactory: registerPluginFactoryDirectiveFactory, + deps: [ PluginServices ] + }, + { + provide: APPEND_SCRIPT_TOKEN, + useFactory: appendScriptFactory, + deps: [ DOCUMENT ] + }, + { + provide: REMOVE_SCRIPT_TOKEN, + useFactory: removeScriptFactory, + deps: [ DOCUMENT ] + }, ] }) diff --git a/src/atlasViewer/pluginUnit/pluginFactory.directive.spec.ts b/src/atlasViewer/pluginUnit/pluginFactory.directive.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..29d723d972543473d647354c82dd829dfaa4dda3 --- /dev/null +++ b/src/atlasViewer/pluginUnit/pluginFactory.directive.spec.ts @@ -0,0 +1,133 @@ +import { async, TestBed } from "@angular/core/testing" +import { PluginFactoryDirective, REGISTER_PLUGIN_FACTORY_DIRECTIVE } from "./pluginFactory.directive" +import { Component, ViewChild } from "@angular/core" +import { APPEND_SCRIPT_TOKEN, REMOVE_SCRIPT_TOKEN } from "src/util/constants" +import { By } from "@angular/platform-browser" + +@Component({ + template: '<div></div>' +}) +class TestCmp{ + + @ViewChild(PluginFactoryDirective) pfd: PluginFactoryDirective +} + +const dummyObj1 = {} +const dummyObj2 = {} +const appendSrcSpy = jasmine.createSpy('appendSrc').and.returnValues( + Promise.resolve(dummyObj1), + Promise.resolve(dummyObj2) +) +const removeSrcSpy = jasmine.createSpy('removeScript') +const registerSpy = jasmine.createSpy('registerSpy') + +describe(`> pluginFactory.directive.ts`, () => { + describe(`> PluginFactoryDirective`, () => { + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + PluginFactoryDirective, + TestCmp + ], + providers: [ + { + provide: APPEND_SCRIPT_TOKEN, + useValue: appendSrcSpy + }, + { + provide: REMOVE_SCRIPT_TOKEN, + useValue: removeSrcSpy + }, + { + provide: REGISTER_PLUGIN_FACTORY_DIRECTIVE, + useValue: registerSpy + } + ] + }).overrideComponent(TestCmp, { + set: { + template: `<div pluginFactoryDirective></div>` + } + }).compileComponents() + })) + + afterEach(() => { + appendSrcSpy.calls.reset() + removeSrcSpy.calls.reset() + registerSpy.calls.reset() + }) + + it('> creates directive', () => { + const fixture = TestBed.createComponent(TestCmp) + fixture.detectChanges() + + const queriedDirective = fixture.debugElement.query( By.directive(PluginFactoryDirective) ) + expect(queriedDirective).toBeTruthy() + }) + + it('> register spy is called', () => { + + const fixture = TestBed.createComponent(TestCmp) + fixture.detectChanges() + expect(registerSpy).toHaveBeenCalledWith(fixture.componentInstance.pfd) + }) + + describe('> loading external libraries', () => { + it('> load once, call append script', async () => { + const fixture = TestBed.createComponent(TestCmp) + fixture.detectChanges() + const pfd = fixture.componentInstance.pfd + await pfd.loadExternalLibraries(['vue@2.5.16']) + expect(appendSrcSpy).toHaveBeenCalledWith('https://cdn.jsdelivr.net/npm/vue@2.5.16/dist/vue.js') + expect(appendSrcSpy).toHaveBeenCalledTimes(1) + }) + + it('> load twice, called append script once', async () => { + const fixture = TestBed.createComponent(TestCmp) + fixture.detectChanges() + const pfd = fixture.componentInstance.pfd + await pfd.loadExternalLibraries(['vue@2.5.16']) + await pfd.loadExternalLibraries(['vue@2.5.16']) + expect(appendSrcSpy).toHaveBeenCalledWith('https://cdn.jsdelivr.net/npm/vue@2.5.16/dist/vue.js') + expect(appendSrcSpy).toHaveBeenCalledTimes(1) + }) + + it('> load unload, call remove script once', async () => { + + const fixture = TestBed.createComponent(TestCmp) + fixture.detectChanges() + const pfd = fixture.componentInstance.pfd + await pfd.loadExternalLibraries(['vue@2.5.16']) + pfd.unloadExternalLibraries(['vue@2.5.16']) + expect(removeSrcSpy).toHaveBeenCalledTimes(1) + }) + + it('> load twice, unload, does not call remove', async () => { + + const fixture = TestBed.createComponent(TestCmp) + fixture.detectChanges() + const pfd = fixture.componentInstance.pfd + await pfd.loadExternalLibraries(['vue@2.5.16']) + await pfd.loadExternalLibraries(['vue@2.5.16']) + pfd.unloadExternalLibraries(['vue@2.5.16']) + expect(removeSrcSpy).not.toHaveBeenCalled() + }) + + it('> load, unload, load, call append script twice', async () => { + + const fixture = TestBed.createComponent(TestCmp) + fixture.detectChanges() + const pfd = fixture.componentInstance.pfd + await pfd.loadExternalLibraries(['vue@2.5.16']) + pfd.unloadExternalLibraries(['vue@2.5.16']) + + appendSrcSpy.calls.reset() + expect(appendSrcSpy).not.toHaveBeenCalled() + + await pfd.loadExternalLibraries(['vue@2.5.16']) + pfd.unloadExternalLibraries(['vue@2.5.16']) + expect(appendSrcSpy).toHaveBeenCalledTimes(1) + }) + }) + }) +}) \ No newline at end of file diff --git a/src/atlasViewer/pluginUnit/pluginFactory.directive.ts b/src/atlasViewer/pluginUnit/pluginFactory.directive.ts index 65081f45eb78a0506b9997b171975f7026c31cb1..fbc76ac7bf830258620539a0301a4cac71778713 100644 --- a/src/atlasViewer/pluginUnit/pluginFactory.directive.ts +++ b/src/atlasViewer/pluginUnit/pluginFactory.directive.ts @@ -1,7 +1,44 @@ -import { Directive, Renderer2, ViewContainerRef } from "@angular/core"; -import { SUPPORT_LIBRARY_MAP } from "src/atlasViewer/atlasViewer.constantService.service"; -import { PluginServices } from "./atlasViewer.pluginService.service"; -import { LoggingService } from "src/logging"; +import { Directive, ViewContainerRef, Inject, Optional } from "@angular/core"; +import { APPEND_SCRIPT_TOKEN, REMOVE_SCRIPT_TOKEN } from "src/util/constants"; + +export const SUPPORT_LIBRARY_MAP: Map<string, Map<string, string>> = new Map([ + ['jquery', new Map<string, string>([ + ['3', 'https://code.jquery.com/jquery-3.3.1.min.js'], + ['2', 'https://code.jquery.com/jquery-2.2.4.min.js'] + ])], + ['webcomponentsLite', new Map([ + ['1.1.0', 'https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.1.0/webcomponents-lite.js'] + ])], + ['react', new Map([ + ['16', 'https://unpkg.com/react@16/umd/react.development.js'] + ])], + ['reactdom', new Map([ + ['16', 'https://unpkg.com/react-dom@16/umd/react-dom.development.js'] + ])], + ['vue', new Map([ + ['2.5.16', 'https://cdn.jsdelivr.net/npm/vue@2.5.16/dist/vue.js'] + ])], + ['preact', new Map([ + ['8.4.2', 'https://cdn.jsdelivr.net/npm/preact@8.4.2/dist/preact.min.js'] + ])], + ['d3', new Map([ + ['5.7.0', 'https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js'] + ])], +]) + +export const parseLibrary = (libVer: string) => { + const re = /^([a-zA-Z0-9]+)@([0-9.]+)$/.exec(libVer) + if (!re) throw new Error(`${libVer} cannot be parsed properly`) + const lib = re[1] + const ver = re[2] + const libMap = SUPPORT_LIBRARY_MAP.get(lib) + if (!libMap) throw new Error(`${lib} not supported. Only supported libraries are ${Array.from(SUPPORT_LIBRARY_MAP.keys())}`) + const src = libMap.get(ver) + if (!src) throw new Error(`${lib} version ${ver} not supported. Only supports ${Array.from(libMap.keys())}`) + return src +} + +export const REGISTER_PLUGIN_FACTORY_DIRECTIVE = `REGISTER_PLUGIN_FACTORY_DIRECTIVE` @Directive({ selector: '[pluginFactoryDirective]', @@ -9,72 +46,53 @@ import { LoggingService } from "src/logging"; export class PluginFactoryDirective { constructor( - pluginService: PluginServices, - viewContainerRef: ViewContainerRef, - private rd2: Renderer2, - private log: LoggingService, + public viewContainerRef: ViewContainerRef, + @Optional() @Inject(REGISTER_PLUGIN_FACTORY_DIRECTIVE) registerPluginFactoryDirective: (directive: PluginFactoryDirective) => void, + @Inject(APPEND_SCRIPT_TOKEN) private appendScript: (src: string) => Promise<HTMLScriptElement>, + @Inject(REMOVE_SCRIPT_TOKEN) private removeScript: (srcEl: HTMLScriptElement) => void, ) { - pluginService.loadExternalLibraries = this.loadExternalLibraries.bind(this) - pluginService.unloadExternalLibraries = this.unloadExternalLibraries.bind(this) - pluginService.pluginViewContainerRef = viewContainerRef - pluginService.appendSrc = (src: HTMLElement) => rd2.appendChild(document.head, src) - pluginService.removeSrc = (src: HTMLElement) => rd2.removeChild(document.head, src) + if (registerPluginFactoryDirective) { + registerPluginFactoryDirective(this) + } } - private loadedLibraries: Map<string, {counter: number, src: HTMLElement|null}> = new Map() + private loadedLibraries: Map<string, {counter: number, srcEl: HTMLScriptElement|null}> = new Map() - loadExternalLibraries(libraries: string[]) { - const srcHTMLElement = libraries.map(libraryName => ({ - name: libraryName, - srcEl: SUPPORT_LIBRARY_MAP.get(libraryName), - })) + async loadExternalLibraries(libraries: string[]) { + const libsToBeLoaded = libraries.map(libName => { + return { + libName, + libSrc: parseLibrary(libName), + } + }) - const rejected = srcHTMLElement.filter(scriptObj => scriptObj.srcEl === null) - if (rejected.length > 0) { - return Promise.reject(`Some library names cannot be recognised. No libraries were loaded: ${rejected.map(srcObj => srcObj.name).join(', ')}`) - } + for (const libToBeLoaded of libsToBeLoaded) { + + const { libSrc, libName } = libToBeLoaded - return Promise.all(srcHTMLElement.map(scriptObj => new Promise((rs, rj) => { - /** - * if browser already support customElements, do not append polyfill - */ - if ('customElements' in window && scriptObj.name === 'webcomponentsLite') { - return rs() - } - const existingEntry = this.loadedLibraries.get(scriptObj.name) - if (existingEntry) { - this.loadedLibraries.set(scriptObj.name, { counter: existingEntry.counter + 1, src: existingEntry.src }) - rs() - } else { - const srcEl = scriptObj.srcEl - srcEl.onload = () => rs() - srcEl.onerror = (e: any) => rj(e) - this.rd2.appendChild(document.head, srcEl) - this.loadedLibraries.set(scriptObj.name, { counter: 1, src: srcEl }) + // if browser natively support custom element, do not append polyfill + if ('customElements' in window && /^webcomponentsLite@/.test(libName)) continue + + let srcEl + const { counter, srcEl: srcElOld } = this.loadedLibraries.get(libName) || { counter: 0 } + if (counter === 0) { + + // slight performance penalty not loading external libraries in parallel, but this should be an edge case any way + srcEl = await this.appendScript(libSrc) } - }))) + this.loadedLibraries.set(libName, { counter: counter + 1, srcEl: srcEl || srcElOld }) + } } unloadExternalLibraries(libraries: string[]) { - libraries - .filter((stringname) => SUPPORT_LIBRARY_MAP.get(stringname) !== null) - .forEach(libname => { - const ledger = this.loadedLibraries.get(libname) - if (!ledger) { - this.log.warn('unload external libraries error. cannot find ledger entry...', libname, this.loadedLibraries) - return - } - if (ledger.src === null) { - this.log.log('webcomponents is native supported. no library needs to be unloaded') - return - } - - if (ledger.counter - 1 == 0) { - this.rd2.removeChild(document.head, ledger.src) - this.loadedLibraries.delete(libname) - } else { - this.loadedLibraries.set(libname, { counter: ledger.counter - 1, src: ledger.src }) - } - }) + for (const lib of libraries) { + const { counter, srcEl } = this.loadedLibraries.get(lib) || { counter: 0 } + if (counter > 1) { + this.loadedLibraries.set(lib, { counter: counter - 1, srcEl }) + } else { + this.loadedLibraries.set(lib, { counter: 0, srcEl: null }) + this.removeScript(srcEl) + } + } } } diff --git a/src/main.module.ts b/src/main.module.ts index 86d315965b857704c0bde1f27eb391d8090b1b18..b7716994d227ae593909b5c19ee19763bbbf3745 100644 --- a/src/main.module.ts +++ b/src/main.module.ts @@ -1,8 +1,8 @@ import { DragDropModule } from '@angular/cdk/drag-drop' -import { CommonModule } from "@angular/common"; +import { CommonModule, DOCUMENT } from "@angular/common"; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from "@angular/core"; import { FormsModule } from "@angular/forms"; -import { StoreModule, Store, select } from "@ngrx/store"; +import { StoreModule, Store } from "@ngrx/store"; import { AngularMaterialModule } from 'src/ui/sharedModules/angularMaterial.module' import { AtlasViewer, NEHUBA_CLICK_OVERRIDE } from "./atlasViewer/atlasViewer.component"; import { ComponentsModule } from "./components/components.module"; @@ -52,6 +52,7 @@ import 'hammerjs' import 'src/res/css/extra_styles.css' import 'src/res/css/version.css' import 'src/theme.scss' +import { APPEND_SCRIPT_TOKEN, appendScriptFactory, REMOVE_SCRIPT_TOKEN, removeScriptFactory } from './util/constants'; @NgModule({ imports : [ @@ -180,6 +181,7 @@ import 'src/theme.scss' deps: [ UIService ] }, + /** * TODO * once nehubacontainer is separated into viewer + overlay, migrate to nehubaContainer module diff --git a/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.spec.ts b/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.spec.ts index 76e62d2f97229bb56498b68ebc5f72c17beaf8ea..a85e3c733286b7fdbe2c7517fb164a9c1c93bdbc 100644 --- a/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.spec.ts +++ b/src/ui/nehubaContainer/nehubaViewer/nehubaViewer.component.spec.ts @@ -4,6 +4,7 @@ import { NehubaViewerUnit, IMPORT_NEHUBA_INJECT_TOKEN } from "./nehubaViewer.com import { importNehubaFactory } from "../util" import { AtlasWorkerService } from "src/atlasViewer/atlasViewer.workerService.service" import { LoggingModule } from "src/logging" +import { APPEND_SCRIPT_TOKEN, appendScriptFactory } from "src/util/constants" describe('nehubaViewer.component,ts', () => { @@ -21,6 +22,11 @@ describe('nehubaViewer.component,ts', () => { { provide: IMPORT_NEHUBA_INJECT_TOKEN, useFactory: importNehubaFactory, + deps: [ APPEND_SCRIPT_TOKEN ] + }, + { + provide: APPEND_SCRIPT_TOKEN, + useFactory: appendScriptFactory, deps: [ DOCUMENT ] }, AtlasWorkerService diff --git a/src/ui/nehubaContainer/util.ts b/src/ui/nehubaContainer/util.ts index 1a622c8eaeb4f393516908ba1ba2be559962568f..f1fbb9348f338683c81fbabe86680140c2f3fc55 100644 --- a/src/ui/nehubaContainer/util.ts +++ b/src/ui/nehubaContainer/util.ts @@ -203,19 +203,13 @@ export const userLmUnchanged = (oldlms, newlms) => { && newlms.every(lm => singleLmUnchanged(lm, oldmap as Map<string, [number, number, number]>)) } -export const importNehubaFactory = (document: Document) => { +export const importNehubaFactory = appendSrc => { let pr: Promise<any> return () => { if ((window as any).export_nehuba) return Promise.resolve() if (pr) return pr - pr = new Promise((rs, rj) => { - const scriptEl = document.createElement('script') - scriptEl.src = 'main.bundle.js' - scriptEl.onload = () => rs() - scriptEl.onerror = (e) => rj(e) - document.head.appendChild(scriptEl) - }) + pr = appendSrc('main.bundle.js') return pr } diff --git a/src/ui/ui.module.ts b/src/ui/ui.module.ts index a66be01bb9d707d5d52051ee7dd24e29e81eeb5e..b58d1b66231b6966622ac15e7fd9f6227a310053 100644 --- a/src/ui/ui.module.ts +++ b/src/ui/ui.module.ts @@ -87,8 +87,9 @@ import { AuthModule } from "src/auth"; import { FabSpeedDialModule } from "src/components/fabSpeedDial"; import { ActionDialog } from "./actionDialog/actionDialog.component"; import { NehubaViewerTouchDirective } from "./nehubaContainer/nehubaViewerInterface/nehubaViewerTouch.directive"; -import { DOCUMENT } from "@angular/common"; import { importNehubaFactory } from "./nehubaContainer/util"; +import { APPEND_SCRIPT_TOKEN, appendScriptFactory } from "src/util/constants"; +import { DOCUMENT } from "@angular/common"; @NgModule({ @@ -189,6 +190,11 @@ import { importNehubaFactory } from "./nehubaContainer/util"; { provide: IMPORT_NEHUBA_INJECT_TOKEN, useFactory: importNehubaFactory, + deps: [ APPEND_SCRIPT_TOKEN ] + }, + { + provide: APPEND_SCRIPT_TOKEN, + useFactory: appendScriptFactory, deps: [ DOCUMENT ] } ], diff --git a/src/util/constants.ts b/src/util/constants.ts index af922497ebe53f2d7c309a58a8fc4d61fbdace1e..901f704818fc711f396f6dbce6a5b525e08e1a05 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -1,3 +1,5 @@ +import { HttpHeaders } from "@angular/common/http" + export const LOCAL_STORAGE_CONST = { GPU_LIMIT: 'fzj.xg.iv.GPU_LIMIT', ANIMATION: 'fzj.xg.iv.ANIMATION_FLAG', @@ -18,4 +20,43 @@ export const MIN_REQ_EXPLAINER = ` - Interactive atlas viewer requires **webgl2.0**, and the \`EXT_color_buffer_float\` extension enabled. - You can check browsers' support of webgl2.0 by visiting <https://caniuse.com/#feat=webgl2> - Unfortunately, Safari and iOS devices currently do not support **webgl2.0**: <https://webkit.org/status/#specification-webgl-2> -` \ No newline at end of file +` + +export const APPEND_SCRIPT_TOKEN = `APPEND_SCRIPT_TOKEN` + +export const appendScriptFactory = (document: Document) => { + return src => new Promise((rs, rj) => { + const scriptEl = document.createElement('script') + scriptEl.src = src + scriptEl.onload = () => rs(scriptEl) + scriptEl.onerror = (e) => rj(e) + document.head.appendChild(scriptEl) + }) +} + +export const REMOVE_SCRIPT_TOKEN = `REMOVE_SCRIPT_TOKEN` + +export const removeScriptFactory = (document: Document) => { + return (srcEl: HTMLScriptElement) => { + document.head.removeChild(srcEl) + } +} + +const getScopedReferer = () => { + const url = new URL(window.location.href) + url.searchParams.delete('regionsSelected') + url.searchParams.delete('cRegionsSelected') + return url.toString() +} + +export const getFetchOption: () => Partial<RequestInit> = () => { + return { + referrer: getScopedReferer() + } +} + +export const getHttpHeader: () => HttpHeaders = () => { + const header = new HttpHeaders() + header.set('referrer', getScopedReferer()) + return header +} \ No newline at end of file