From 6dd275cd3c37ccb92c2b88e06b42c11343efeb0d Mon Sep 17 00:00:00 2001 From: Xiao Gui <xgui3783@gmail.com> Date: Tue, 26 Apr 2022 12:11:01 +0200 Subject: [PATCH] reworked plugin system see src/plugin/{README,MIGRATION}.md for deteails --- package.json | 5 +- src/api/index.ts | 10 + src/api/jsonrpc.ts | 111 +++++ src/api/service.ts | 431 +++++++++++++++++ src/atlasComponents/sapi/index.ts | 1 + .../entryListItem/entryListItem.component.ts | 5 +- .../atlasViewer.apiService.service.spec.ts | 281 ----------- .../atlasViewer.apiService.service.ts | 447 ------------------ src/atlasViewer/atlasViewer.style.css | 19 + src/atlasViewer/atlasViewer.template.html | 8 +- src/extra_styles.css | 12 - src/main.module.ts | 4 +- src/messaging/service.ts | 20 +- src/messaging/types.ts | 8 - src/plugin/MIGRATION.md | 31 ++ src/plugin/README.md | 29 ++ .../atlasViewer.pluginService.service.spec.ts | 300 ------------ .../atlasViewer.pluginService.service.ts | 412 ---------------- src/plugin/broadcast.md | 64 +++ src/plugin/const.ts | 12 + src/plugin/generateTypes.js | 96 ++++ .../pluginCsp.style.css => handshake.md} | 0 src/plugin/iframeSrc.pipe.ts | 23 + src/plugin/index.ts | 6 - src/plugin/plugin.module.ts | 22 +- .../pluginBanner/pluginBanner.component.ts | 41 +- .../pluginBanner/pluginBanner.template.html | 73 +-- src/plugin/pluginCsp/pluginCsp.component.ts | 32 -- src/plugin/pluginCsp/pluginCsp.template.html | 52 -- src/plugin/pluginFactory.directive.spec.ts | 133 ------ src/plugin/pluginFactory.directive.ts | 102 ---- .../pluginPortal/pluginPortal.component.ts | 147 ++++++ src/plugin/pluginUnit/pluginUnit.component.ts | 15 - .../pluginUnit/pluginUnit.template.html | 0 src/plugin/request.md | 142 ++++++ src/plugin/service.ts | 57 +++ src/plugin/tsUtil.js | 129 +++++ src/plugin/types.ts | 4 + .../routeStateTransform.service.spec.ts | 14 +- src/state/plugins/effects.spec.ts | 105 ++-- src/state/plugins/effects.ts | 20 +- src/state/userInteraction/actions.ts | 10 +- src/state/userInteraction/selectors.ts | 5 + src/state/userInteraction/store.ts | 15 +- src/ui/config/configCmp/config.template.html | 5 - src/util/interfaces.ts | 5 + src/viewerModule/module.ts | 17 - .../nehubaViewerGlue.component.ts | 6 - .../threeSurferGlue/threeSurfer.component.ts | 70 --- .../viewerCmp/viewerCmp.component.ts | 25 +- .../viewerCmp/viewerCmp.template.html | 2 +- src/widget/constants.ts | 2 + src/widget/index.ts | 4 +- src/widget/service.ts | 48 ++ src/widget/widget.module.ts | 26 +- src/widget/widgetCanvas.directive.ts | 15 + .../widgetPortal/widgetPortal.component.ts | 41 ++ .../widgetPortal/widgetPortal.style.css | 56 +++ .../widgetPortal/widgetPortal.template.html | 22 + src/widget/widgetService.service.ts | 217 --------- src/widget/widgetUnit/widgetUnit.component.ts | 180 ------- src/widget/widgetUnit/widgetUnit.style.css | 91 ---- .../widgetUnit/widgetUnit.template.html | 42 -- 63 files changed, 1675 insertions(+), 2652 deletions(-) create mode 100644 src/api/index.ts create mode 100644 src/api/jsonrpc.ts create mode 100644 src/api/service.ts delete mode 100644 src/atlasViewer/atlasViewer.apiService.service.spec.ts delete mode 100644 src/atlasViewer/atlasViewer.apiService.service.ts create mode 100644 src/plugin/MIGRATION.md create mode 100644 src/plugin/README.md delete mode 100644 src/plugin/atlasViewer.pluginService.service.spec.ts delete mode 100644 src/plugin/atlasViewer.pluginService.service.ts create mode 100644 src/plugin/broadcast.md create mode 100644 src/plugin/const.ts create mode 100644 src/plugin/generateTypes.js rename src/plugin/{pluginCsp/pluginCsp.style.css => handshake.md} (100%) create mode 100644 src/plugin/iframeSrc.pipe.ts delete mode 100644 src/plugin/pluginCsp/pluginCsp.component.ts delete mode 100644 src/plugin/pluginCsp/pluginCsp.template.html delete mode 100644 src/plugin/pluginFactory.directive.spec.ts delete mode 100644 src/plugin/pluginFactory.directive.ts create mode 100644 src/plugin/pluginPortal/pluginPortal.component.ts delete mode 100644 src/plugin/pluginUnit/pluginUnit.component.ts delete mode 100644 src/plugin/pluginUnit/pluginUnit.template.html create mode 100644 src/plugin/request.md create mode 100644 src/plugin/service.ts create mode 100644 src/plugin/tsUtil.js create mode 100644 src/plugin/types.ts create mode 100644 src/widget/service.ts create mode 100644 src/widget/widgetCanvas.directive.ts create mode 100644 src/widget/widgetPortal/widgetPortal.component.ts create mode 100644 src/widget/widgetPortal/widgetPortal.style.css create mode 100644 src/widget/widgetPortal/widgetPortal.template.html delete mode 100644 src/widget/widgetService.service.ts delete mode 100644 src/widget/widgetUnit/widgetUnit.component.ts delete mode 100644 src/widget/widgetUnit/widgetUnit.style.css delete mode 100644 src/widget/widgetUnit/widgetUnit.template.html diff --git a/package.json b/package.json index 5d8ce9d1a..cb7b98d4f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "interactive-viewer", "version": "2.7.0", - "description": "HBP interactive atlas viewer. Integrating KG query, dataset previews & more. Based on humanbrainproject/nehuba & google/neuroglancer. Built with angular", + "description": "siibra-explorer - explore brain atlases. Based on humanbrainproject/nehuba & google/neuroglancer. Built with angular", "scripts": { "build-aot": "ng build && node ./third_party/matomo/processMatomo.js", "dev-server-aot": "ng serve", @@ -15,7 +15,10 @@ "watch": "ng build --watch --configuration development", "test": "ng test", "test-ci": "ng test --progress false --watch false --browsers=ChromeHeadless", + "sapi-schema": "npx openapi-typescript@5.1.1 http://localhost:5000/v1_0/openapi.json --output ./src/atlasComponents/sapi/schema.ts && eslint ./src/atlasComponents/sapi/schema.ts --no-ignore --fix", + "api-schema": "node src/plugin/generateTypes.js", + "docs:json": "compodoc -p ./tsconfig.json -e json -d .", "storybook": "npm run docs:json && start-storybook -p 6006", "build-storybook": "npm run docs:json && build-storybook" diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 000000000..51c31a211 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,10 @@ +export { + JRPCRequest, + JRPCResp, + JRPCSuccessResp, + JRPCErrorResp, +} from "./jsonrpc" + +export { + ApiService, +} from "./service" diff --git a/src/api/jsonrpc.ts b/src/api/jsonrpc.ts new file mode 100644 index 000000000..3c79f42a0 --- /dev/null +++ b/src/api/jsonrpc.ts @@ -0,0 +1,111 @@ +type JRPCBase = { jsonrpc: "2.0" } + +export type JRPCRequest<Method, T> = { + method: Method // does NOT start with rpc. + params?: T + id?: string // if absent, notification, does not require response +} & JRPCBase + +export type JRPCSuccessResp<T> = { + result: T + id?: string +} & JRPCBase + +export type JRPCErrorResp<T> = { + error: { + /** + * + * -32700 Parse error Invalid JSON was received by the server.An error occurred on the server while parsing the JSON text. + * -32600 Invalid Request The JSON sent is not a valid Request object. + * -32601 Method not found The method does not exist / is not available. + * -32602 Invalid params Invalid method parameter(s). + * -32603 Internal error Internal JSON-RPC error. + * -32000 to -32099 Server error Reserved for implementation-defined server-errors. + */ + code: number + message: string + data?: T + } +} & JRPCBase + +export type JRPCResp<T, E> = JRPCSuccessResp<T> | JRPCErrorResp<E> + +export interface ListenerChannel { + notify: (payload: JRPCRequest<unknown, unknown>) => void + registerLeaveCb: (cb: () => void) => void +} + +export type BroadcastChannel< + Protocols extends Record<string, unknown>, +> = { + state: Protocols + listeners: ListenerChannel[] + emit: (event: keyof Protocols, payload: Protocols[keyof Protocols]) => void + addListener: (listener: ListenerChannel) => void +} + +export function createBroadcastingJsonRpcChannel< + NameSpace extends string, + Protocols extends Record<keyof Protocols, unknown> +>(namespace: NameSpace, defaultState: Protocols): BroadcastChannel<Protocols>{ + return { + state: defaultState, + listeners: [], + emit(event: keyof Protocols, value: Protocols[keyof Protocols]) { + const ev = `${namespace}${event as string}` + this.state[event] = value + const payload: Omit<JRPCRequest<string, Protocols[keyof Protocols]>, 'id'> = { + jsonrpc: '2.0', + method: ev, + params: this.state[event] + } + for (const listener of (this.listeners as ListenerChannel[])) { + listener.notify(payload) + } + }, + addListener(listener: ListenerChannel){ + if (this.listeners.indexOf(listener) < 0) { + this.listeners.push(listener) + } + listener.registerLeaveCb(() => { + this.listeners = this.listeners.filter(l => l !== listener) + }) + for (const key in this.state) { + const payload: Omit<JRPCRequest<string, Protocols[keyof Protocols]>,'id'> = { + jsonrpc: '2.0', + method: `${namespace}.${key}`, + params: this.state[key] + } + listener.notify(payload) + } + } + } +} + +type BoothProtocol = Record<string, { + request: unknown + response: unknown +}> + +export class BoothVisitor<T extends BoothProtocol>{ + constructor(private booth: Booth<T>){ + + } + request(event: JRPCRequest<keyof T, T[keyof T]['request']>) { + return this.booth.responder.onRequest(event) + } +} + +export interface BoothResponder<RespParam extends BoothProtocol>{ + onRequest: (event: JRPCRequest<keyof RespParam, RespParam[keyof RespParam]['request']>) => Promise<void | JRPCResp<RespParam[keyof RespParam]['response'], string>> +} + +export class Booth<T extends BoothProtocol>{ + constructor( + public responder: BoothResponder<T> + ){ + } + handshake() { + return new BoothVisitor<T>(this) + } +} diff --git a/src/api/service.ts b/src/api/service.ts new file mode 100644 index 000000000..0e47842b9 --- /dev/null +++ b/src/api/service.ts @@ -0,0 +1,431 @@ +import { Inject, Injectable, Optional } from "@angular/core"; +import { select, Store } from "@ngrx/store"; +import { Subject } from "rxjs"; +import { distinctUntilChanged, filter, map, take } from "rxjs/operators"; +import { SAPI, SapiAtlasModel, SapiParcellationModel, SapiRegionModel, SapiSpaceModel, OpenMINDSCoordinatePoint } from "src/atlasComponents/sapi"; +import { MainState, atlasSelection, userInteraction } from "src/state" +import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR } from "src/util"; +import { CANCELLABLE_DIALOG, CANCELLABLE_DIALOG_OPTS } from "src/util/interfaces"; +import { Booth, BoothResponder, createBroadcastingJsonRpcChannel, JRPCRequest, JRPCResp } from "./jsonrpc" + +export type NAMESPACE_TYPE = "sxplr" +export const namespace: NAMESPACE_TYPE = "sxplr" +const nameSpaceRegex = new RegExp(`^${namespace}`) + +type AtId = { + "@id": string +} + +type RequestUserTypes = { + region: SapiRegionModel + point: OpenMINDSCoordinatePoint + confirm: void + input: string +} + +type RequestUser<T extends keyof RequestUserTypes> = { + type: T + message: string + promise: Promise<RequestUserTypes[T]> + id: string + rs: (arg: RequestUserTypes[T]) => void + rj: (reason: string) => void +} + +export type ApiBoothEvents = { + getAllAtlases: { + request: null + response: SapiAtlasModel[] + } + getSupportedTemplates: { + request: null + response: SapiSpaceModel[] + } + getSupportedParcellations: { + request: null + response: SapiParcellationModel[] + } + + selectAtlas: { + request: AtId + response: 'OK' + } + selectParcellation: { + request: AtId + response: 'OK' + } + selectTemplate: { + request: AtId + response: 'OK' + } + + navigateTo: { + request: MainState['[state.atlasSelection]']['navigation'] & { animate?: boolean } + response: 'OK' + } + + getUserToSelectARoi: { + request: { + type: 'region' | 'point' + message: string + } + response: SapiRegionModel | OpenMINDSCoordinatePoint + } + + cancelRequest: { + request: { + id: string + } + response: 'OK' + } +} + +export type HeartbeatEvents = { + init: { + request: null + response: { + name: string + } + } +} + +export type BroadCastingApiEvents = { + atlasSelected: SapiAtlasModel + templateSelected: SapiSpaceModel + parcellationSelected: SapiParcellationModel + allRegions: SapiRegionModel[] + regionsSelected: SapiRegionModel[] +} + +const broadCastDefault: BroadCastingApiEvents = { + atlasSelected: null, + templateSelected: null, + parcellationSelected: null, + allRegions: [], + regionsSelected: [], +} + +@Injectable({ + providedIn: 'root' +}) + +export class ApiService implements BoothResponder<ApiBoothEvents>{ + + public broadcastCh = createBroadcastingJsonRpcChannel<`${NAMESPACE_TYPE}.on`, BroadCastingApiEvents>(`${namespace}.on`, broadCastDefault) + public booth = new Booth<ApiBoothEvents>(this) + + private requestUserQueue: RequestUser<keyof RequestUserTypes>[] = [] + private requestUser$ = new Subject<RequestUser<keyof RequestUserTypes>>() + private fulfillUserRequest(error: string, result: RequestUserTypes[keyof RequestUserTypes]){ + const { + rs, rj + } = this.requestUserQueue.pop() + if (!!error) { + rj(error) + } else { + rs(result) + } + if (this.dismissDialog) { + this.dismissDialog() + this.dismissDialog = null + } + if (this.requestUserQueue.length > 0) { + this.requestUser$.next(this.requestUserQueue[0]) + } + } + private dismissDialog: () => void + private onMouseClick(): boolean { + if (this.requestUserQueue.length === 0) return true + + const { type } = this.requestUserQueue[0] + + if (type === "region") { + let moRegion: SapiRegionModel + this.store.pipe( + select(userInteraction.selectors.mousingOverRegions), + filter(val => val.length > 0), + map(val => val[0]), + take(1) + ).subscribe(region => moRegion = region) + if (!!moRegion) { + this.fulfillUserRequest(null, moRegion) + return false + } + } + + if (type === "point") { + let point: OpenMINDSCoordinatePoint + this.store.pipe( + select(userInteraction.selectors.mousingOverPosition), + take(1) + ).subscribe(p => point = p) + if (!!point) { + this.fulfillUserRequest(null, point) + return false + } + } + return true + } + + private onDestoryCb: (() => void)[] = [] + constructor( + private store: Store, + private sapi: SAPI, + @Optional() @Inject(CANCELLABLE_DIALOG) openCancellableDialog: (message: string, options: CANCELLABLE_DIALOG_OPTS) => () => void, + @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) clickInterceptor: ClickInterceptor, + ){ + + if (clickInterceptor) { + const { register, deregister } = clickInterceptor + const onMouseClick = this.onMouseClick.bind(this) + register(onMouseClick) + this.onDestoryCb.push(() => deregister(onMouseClick)) + } + + if (openCancellableDialog) { + + const requestUsersSub = this.requestUser$.pipe( + distinctUntilChanged((o, n) => o?.promise === n?.promise) + ).subscribe(item => { + if (this.dismissDialog) this.dismissDialog() + if (!item) return + this.dismissDialog = openCancellableDialog(item.message, { + userCancelCallback: () => { + this.fulfillUserRequest(`user Cancelled`, null) + this.dismissDialog = null + } + }) + }) + this.onDestoryCb.push(() => requestUsersSub.unsubscribe()) + } + + this.store.pipe( + select(atlasSelection.selectors.selectedAtlas) + ).subscribe(atlas => { + this.broadcastCh.emit('atlasSelected', atlas) + }) + this.store.pipe( + select(atlasSelection.selectors.selectedParcellation) + ).subscribe(parcellation => { + this.broadcastCh.emit('parcellationSelected', parcellation) + }) + this.store.pipe( + select(atlasSelection.selectors.selectedTemplate) + ).subscribe(template => { + this.broadcastCh.emit('templateSelected', template) + }) + this.store.pipe( + select(atlasSelection.selectors.selectedRegions) + ).subscribe(regions => { + this.broadcastCh.emit('regionsSelected', regions) + }) + this.store.pipe( + select(atlasSelection.selectors.selectedParcAllRegions) + ).subscribe(regions => { + this.broadcastCh.emit('allRegions', regions) + }) + } + async onRequest(event: JRPCRequest<keyof ApiBoothEvents, null>): Promise<void | JRPCResp<ApiBoothEvents[keyof ApiBoothEvents]['response'], string>> { + /** + * if id is not present, then it's a no-op + */ + if (!event.id) { + return + } + if (!nameSpaceRegex.test(event.method)) return + + const method = event.method.replace(nameSpaceRegex, '').replace(/^\./, '') + switch (method) { + case 'getAllAtlases': { + if (!event.id) return + const atlases = await this.sapi.atlases$.pipe( + take(1) + ).toPromise() + return { + id: event.id, + result: atlases, + jsonrpc: '2.0' + } + } + case 'getSupportedParcellations': { + if (!event.id) return + const parcs = await this.store.pipe( + atlasSelection.fromRootStore.allAvailParcs(this.sapi), + take(1) + ).toPromise() + return { + id: event.id, + jsonrpc: '2.0', + result: parcs + } + } + case 'getSupportedTemplates': { + if (!event.id) return + const spaces = await this.store.pipe( + atlasSelection.fromRootStore.allAvailSpaces(this.sapi), + take(1) + ).toPromise() + return { + id: event.id, + jsonrpc: '2.0', + result: spaces + } + } + case 'selectAtlas': { + const atlases = await this.sapi.atlases$.pipe( + take(1) + ).toPromise() + const id = event.params as ApiBoothEvents['selectAtlas']['request'] + const atlas = atlases.find(atlas => atlas["@id"] === id?.["@id"]) + if (!atlas) { + if (!!event.id) { + return { + id: event.id, + jsonrpc: '2.0', + error: { + code: -32602, + message:`atlas id ${id?.["@id"]} not found` + } + } + } + return + } + this.store.dispatch( + atlasSelection.actions.selectAtlas({ atlas }) + ) + if (!!event.id) { + return { + jsonrpc: '2.0', + id: event.id, + result: null + } + } + } + case 'selectParcellation': { + if (!!event.id) { + return { + jsonrpc: '2.0', + id: event.id, + error: { + code: -32601, + message: `NYI` + } + } + } + } + case 'selectTemplate': { + if (!!event.id) { + return { + jsonrpc: '2.0', + id: event.id, + error: { + code: -32601, + message: `NYI` + } + } + } + } + case 'navigateTo': { + const { animate, ...navigation } = event.params as ApiBoothEvents['navigateTo']['request'] + this.store.dispatch( + atlasSelection.actions.navigateTo({ + navigation, + animation: !!animate + }) + ) + if (!!event.id) { + const timeoutDuration = !!animate + ? 500 + : 0 + await new Promise(rs => setTimeout(rs, timeoutDuration)) + return { + id: event.id, + jsonrpc: '2.0', + result: null + } + } + } + case 'getUserToSelectARoi': { + const { params, id } = event as JRPCRequest<'getUserToSelectARoi', ApiBoothEvents['getUserToSelectARoi']['request']> + const { type, message } = params + if (!params || (type !== "region" && type !== "point")) { + return { + id: event.id, + jsonrpc: '2.0', + error: { + code: -32602, + message: `type must be either region or point!` + } + } + } + let rs, rj, promise = new Promise<RequestUserTypes['region'] | RequestUserTypes['point']>((_rs, _rj) => { + rs = _rs + rj = _rj + }) + this.requestUserQueue.push({ + message, + promise, + id, + type: type as 'region' | 'point', + rj, + rs + }) + this.requestUser$.next( + this.requestUserQueue[0] + ) + return promise.then(val => { + return { + id, + jsonrpc: '2.0', + result: val + } + }) + } + case 'cancelRequest': { + const { id } = event.params as ApiBoothEvents['cancelRequest']['request'] + const idx = this.requestUserQueue.findIndex(q => q.id === id) + if (idx < 0) { + if (!!event.id) { + return { + jsonrpc: '2.0', + id: event.id, + error: { + code: -1, + message: `cancelRequest failed, request with id ${id} does not exist, or has already been resolved.` + } + } + } + return + } + const req = this.requestUserQueue.splice(idx, 1) + req[0].rj(`client cancelled`) + + this.requestUser$.next( + this.requestUserQueue[0] + ) + + if (!!event.id) { + return { + jsonrpc: '2.0', + id: event.id, + result: null + } + } + break + } + default: { + const message = `Method ${event.method} not found.` + if (!!event.id) { + return { + jsonrpc: '2.0', + id: event.id, + error: { + code: -32601, + message + } + } + } + } + } + } +} diff --git a/src/atlasComponents/sapi/index.ts b/src/atlasComponents/sapi/index.ts index 4c890621d..2c223648d 100644 --- a/src/atlasComponents/sapi/index.ts +++ b/src/atlasComponents/sapi/index.ts @@ -13,6 +13,7 @@ export { SapiParcellationFeatureModel, CleanedIeegDataset, SxplrCleanedFeatureModel, + OpenMINDSCoordinatePoint, CLEANED_IEEG_DATASET_TYPE, } from "./type" diff --git a/src/atlasComponents/sapiViews/features/entryListItem/entryListItem.component.ts b/src/atlasComponents/sapiViews/features/entryListItem/entryListItem.component.ts index 6fb2e3830..3b915ff24 100644 --- a/src/atlasComponents/sapiViews/features/entryListItem/entryListItem.component.ts +++ b/src/atlasComponents/sapiViews/features/entryListItem/entryListItem.component.ts @@ -1,5 +1,5 @@ import { Component, Input } from "@angular/core"; -import { SapiFeatureModel, SapiRegionalFeatureModel, SapiSpatialFeatureModel, SapiParcellationFeatureModel } from "src/atlasComponents/sapi"; +import { SapiFeatureModel } from "src/atlasComponents/sapi"; import { CleanedIeegDataset, CLEANED_IEEG_DATASET_TYPE, SapiDatasetModel, SapiParcellationFeatureMatrixModel, SapiRegionalFeatureReceptorModel, SapiSerializationErrorModel, SapiVOIDataResponse, SxplrCleanedFeatureModel } from "src/atlasComponents/sapi/type"; @Component({ @@ -21,7 +21,8 @@ export class SapiViewsFeaturesEntryListItem{ if (!this.feature) return null const { '@type': type } = this.feature if ( - type === "siibra/core/dataset" || + type === "https://openminds.ebrains.eu/core/DatasetVersion" || + type === "siibra/features/cells" || type === "siibra/features/receptor" || type === "siibra/features/voi" || type === CLEANED_IEEG_DATASET_TYPE diff --git a/src/atlasViewer/atlasViewer.apiService.service.spec.ts b/src/atlasViewer/atlasViewer.apiService.service.spec.ts deleted file mode 100644 index c4800524b..000000000 --- a/src/atlasViewer/atlasViewer.apiService.service.spec.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { AtlasViewerAPIServices } from "src/atlasViewer/atlasViewer.apiService.service"; -import { async, TestBed, fakeAsync, tick } from "@angular/core/testing"; -import { provideMockStore } from "@ngrx/store/testing"; -import { AngularMaterialModule } from "src/sharedModules"; -import { WidgetModule } from 'src/widget'; -import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; -import { PluginServices } from "src/plugin"; -import { CANCELLABLE_DIALOG } from "src/util/interfaces"; - -describe('atlasViewer.apiService.service.ts', () => { - /** - * TODO - * plugin api to be redesigned - */ - // describe('AtlasViewerAPIServices', () => { - - // const cancelTokenSpy = jasmine.createSpy('cancelToken') - // const cancellableDialogSpy = jasmine.createSpy('openCallableDialog').and.returnValue(cancelTokenSpy) - - // afterEach(() => { - // cancelTokenSpy.calls.reset() - // cancellableDialogSpy.calls.reset() - - // const ctrl = TestBed.inject(HttpTestingController) - // ctrl.verify() - // }) - - // beforeEach(async(() => { - // TestBed.configureTestingModule({ - // imports: [ - // AngularMaterialModule, - // HttpClientTestingModule, - // WidgetModule, - // ], - // providers: [ - // AtlasViewerAPIServices, - // provideMockStore(), - // { - // provide: CANCELLABLE_DIALOG, - // useValue: cancellableDialogSpy - // }, - // { - // provide: PluginServices, - // useValue: {} - // } - // ] - // }).compileComponents() - // })) - - // it('service exists', () => { - // const service = TestBed.inject(AtlasViewerAPIServices) - // expect(service).not.toBeNull() - // }) - - // describe('uiHandle', () => { - - // describe('getUserToSelectARegion', () => { - - // it('on init, expect getUserToSelectRegion to be length 0', () => { - // const service = TestBed.inject(AtlasViewerAPIServices) - // expect(service.getUserToSelectRegion.length).toEqual(0) - // }) - // it('calling getUserToSelectARegion() populates getUserToSelectRegion', () => { - // const service = TestBed.inject(AtlasViewerAPIServices) - - // const pr = service.interactiveViewer.uiHandle.getUserToSelectARegion('hello world') - - // expect(service.getUserToSelectRegion.length).toEqual(1) - // const { promise, message, rs, rj } = service.getUserToSelectRegion[0] - // expect(promise).toEqual(pr) - // expect(message).toEqual('hello world') - - // expect(rs).not.toBeUndefined() - // expect(rs).not.toBeNull() - - // expect(rj).not.toBeUndefined() - // expect(rj).not.toBeNull() - // }) - // }) - - // describe('> getUserToSelectRoi', () => { - // it('> calling getUserToSelectRoi without spec throws error', () => { - // const service = TestBed.inject(AtlasViewerAPIServices) - // expect(() => { - // service.interactiveViewer.uiHandle.getUserToSelectRoi('hello world') - // }).toThrow() - // }) - - // it('> calling getUserToSelectRoi without spec.type throws', () => { - // const service = TestBed.inject(AtlasViewerAPIServices) - // expect(() => { - // service.interactiveViewer.uiHandle.getUserToSelectRoi('hello world', { foo: 'bar' } as any) - // }).toThrow() - // }) - - // it('> calling getUserToSelectRoi populates getUserToSelectRegion with malformed spec.type is fine', () => { - // const service = TestBed.inject(AtlasViewerAPIServices) - // expect(() => { - // service.interactiveViewer.uiHandle.getUserToSelectRoi('hello world', { type: 'foobar' }) - // }).not.toThrow() - // }) - // it('> calling getUserToSelectRoi populates getUserToSelectRegion', () => { - - // const service = TestBed.inject(AtlasViewerAPIServices) - - // const pr = service.interactiveViewer.uiHandle.getUserToSelectRoi('hello world', { type: 'POINT' }) - - // expect(service.getUserToSelectRegion.length).toEqual(1) - // const { promise, message, spec, rs, rj } = service.getUserToSelectRegion[0] - // expect(promise).toEqual(pr) - // expect(message).toEqual('hello world') - // expect(spec).toEqual({ type: 'POINT' }) - - // expect(rs).not.toBeFalsy() - // expect(rj).not.toBeFalsy() - // }) - // }) - - // describe('cancelPromise', () => { - // it('calling cancelPromise removes pr from getUsertoSelectRegion', done => { - - // const service = TestBed.inject(AtlasViewerAPIServices) - // const pr = service.interactiveViewer.uiHandle.getUserToSelectARegion('test') - // pr.catch(e => { - // expect(e.userInitiated).toEqual(false) - // expect(service.getUserToSelectRegion.length).toEqual(0) - // done() - // }) - // service.interactiveViewer.uiHandle.cancelPromise(pr) - // }) - - // it('alling cancelPromise on non existing promise, throws ', () => { - - // const service = TestBed.inject(AtlasViewerAPIServices) - // const pr = service.interactiveViewer.uiHandle.getUserToSelectARegion('test') - // service.interactiveViewer.uiHandle.cancelPromise(pr) - // expect(() => { - // service.interactiveViewer.uiHandle.cancelPromise(pr) - // }).toThrow() - // }) - // }) - - // describe('getUserToSelectARegion, cancelPromise and userCancel', () => { - // it('if token is provided, on getUserToSelectRegionUI$ next should follow by call to injected function', () => { - // const service = TestBed.inject(AtlasViewerAPIServices) - - // const rsSpy = jasmine.createSpy('rs') - // const rjSpy = jasmine.createSpy('rj') - // const mockObj = { - // message: 'test', - // promise: new Promise((rs, rj) => {}), - // rs: rsSpy, - // rj: rjSpy, - // } - // service.getUserToSelectRegionUI$.next([ mockObj ]) - - - // expect(cancellableDialogSpy).toHaveBeenCalled() - - // const arg = cancellableDialogSpy.calls.mostRecent().args - // expect(arg[0]).toEqual('test') - // expect(arg[1].userCancelCallback).toBeTruthy() - // }) - - // it('if multiple regionUIs are provided, only the last one is used', () => { - // const service = TestBed.inject(AtlasViewerAPIServices) - - // const rsSpy = jasmine.createSpy('rs') - // const rjSpy = jasmine.createSpy('rj') - // const mockObj1 = { - // message: 'test1', - // promise: new Promise((rs, rj) => {}), - // rs: rsSpy, - // rj: rjSpy, - // } - // const mockObj2 = { - // message: 'test2', - // promise: new Promise((rs, rj) => {}), - // rs: rsSpy, - // rj: rjSpy, - // } - // service.getUserToSelectRegionUI$.next([ mockObj1, mockObj2 ]) - - // expect(cancellableDialogSpy).toHaveBeenCalled() - - // const arg = cancellableDialogSpy.calls.mostRecent().args - // expect(arg[0]).toEqual('test2') - // expect(arg[1].userCancelCallback).toBeTruthy() - // }) - - // describe('calling userCacellationCb', () => { - - // it('correct usage => in removeBasedOnPr called, rj with userini as true', fakeAsync(() => { - // const service = TestBed.inject(AtlasViewerAPIServices) - - // const rsSpy = jasmine.createSpy('rs') - // const rjSpy = jasmine.createSpy('rj') - // const promise = new Promise((rs, rj) => {}) - // const mockObj = { - // message: 'test', - // promise, - // rs: rsSpy, - // rj: rjSpy, - // } - - // const removeBaseOnPr = spyOn(service, 'removeBasedOnPr').and.returnValue(null) - - // service.getUserToSelectRegionUI$.next([ mockObj ]) - // const arg = cancellableDialogSpy.calls.mostRecent().args - // const cb = arg[1].userCancelCallback - // cb() - // tick(100) - // expect(rjSpy).toHaveBeenCalledWith({ userInitiated: true }) - // expect(removeBaseOnPr).toHaveBeenCalledWith(promise, { userInitiated: true }) - - // })) - - // it('incorrect usage (resolve) => removebasedonpr, rj not called', fakeAsync(() => { - - // const service = TestBed.inject(AtlasViewerAPIServices) - - // const dummyObj = { - // hello:'world' - // } - - // const rsSpy = jasmine.createSpy('rs') - // const rjSpy = jasmine.createSpy('rj') - // const promise = Promise.resolve(dummyObj) - // const mockObj = { - // message: 'test', - // promise, - // rs: rsSpy, - // rj: rjSpy, - // } - - // const removeBaseOnPr = spyOn(service, 'removeBasedOnPr').and.returnValue(null) - - // service.getUserToSelectRegionUI$.next([ mockObj ]) - // const arg = cancellableDialogSpy.calls.mostRecent().args - // const cb = arg[1].userCancelCallback - // cb() - // tick(100) - // expect(rjSpy).not.toHaveBeenCalled() - // expect(removeBaseOnPr).not.toHaveBeenCalled() - - // })) - - // it('incorrect usage (reject) => removebasedonpr, rj not called', fakeAsync(() => { - - // const service = TestBed.inject(AtlasViewerAPIServices) - - // const dummyObj = { - // hello:'world' - // } - - // const rsSpy = jasmine.createSpy('rs') - // const rjSpy = jasmine.createSpy('rj') - // const promise = Promise.reject(dummyObj) - // const mockObj = { - // message: 'test', - // promise, - // rs: rsSpy, - // rj: rjSpy, - // } - - // const removeBaseOnPr = spyOn(service, 'removeBasedOnPr').and.returnValue(null) - - // service.getUserToSelectRegionUI$.next([ mockObj ]) - // const arg = cancellableDialogSpy.calls.mostRecent().args - // const cb = arg[1].userCancelCallback - // cb() - // tick(100) - // expect(rjSpy).not.toHaveBeenCalled() - // expect(removeBaseOnPr).not.toHaveBeenCalled() - - // })) - // }) - // }) - // }) - // }) -}) diff --git a/src/atlasViewer/atlasViewer.apiService.service.ts b/src/atlasViewer/atlasViewer.apiService.service.ts deleted file mode 100644 index eae9a9c68..000000000 --- a/src/atlasViewer/atlasViewer.apiService.service.ts +++ /dev/null @@ -1,447 +0,0 @@ -/* eslint-disable @typescript-eslint/no-empty-function */ -import {Injectable, NgZone, Optional, Inject, OnDestroy, InjectionToken} from "@angular/core"; -import { MatSnackBar } from "@angular/material/snack-bar"; -import { select, Store } from "@ngrx/store"; -import { Observable, Subject, Subscription, from, race, of, } from "rxjs"; -import { distinctUntilChanged, map, filter, startWith, switchMap, catchError, mapTo, take, shareReplay } from "rxjs/operators"; -import { DialogService } from "src/services/dialogService.service"; - -import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR } from "src/util"; -import { FRAGMENT_EMIT_RED } from "src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component"; -import { IPluginManifest, PluginServices } from "src/plugin"; -import { ILoadMesh } from 'src/messaging/types' -import { CANCELLABLE_DIALOG } from "src/util/interfaces"; -import { atlasSelection, userInteraction } from "src/state" -import { SapiRegionModel } from "src/atlasComponents/sapi"; - -declare let window - -interface IRejectUserInput{ - userInitiated: boolean - reason?: string -} - -interface IGetUserSelectRegionPr{ - message: string - promise: Promise<any> - spec?: ICustomRegionSpec - rs: (region: any) => void - rj: (reject: IRejectUserInput) => void -} - -@Injectable({ - providedIn : 'root' -}) - -export class AtlasViewerAPIServices implements OnDestroy{ - - public loadMesh$ = new Subject<ILoadMesh>() - - private onDestoryCb: (() => void)[] = [] - private loadedTemplates$: Observable<any> - private selectParcellation$: Observable<any> - public interactiveViewer: IInteractiveViewerInterface - - public loadedLibraries: Map<string, {counter: number, src: HTMLElement|null}> = new Map() - - public removeBasedOnPr = (pr: Promise<any>, {userInitiated = false} = {}) => { - - const idx = this.getUserToSelectRegion.findIndex(({ promise }) => promise === pr) - if (idx >=0) { - const { rj } = this.getUserToSelectRegion.splice(idx, 1)[0] - this.getUserToSelectRegionUI$.next([...this.getUserToSelectRegion]) - this.zone.run(() => { }) - rj({ userInitiated }) - } - else throw new Error(`This promise has already been fulfilled.`) - - } - - private dismissDialog: () => void - public getUserToSelectRegion: IGetUserSelectRegionPr[] = [] - public getUserToSelectRegionUI$: Subject<IGetUserSelectRegionPr[]> = new Subject() - - public getNextUserRegionSelectHandler: () => IGetUserSelectRegionPr = () => { - if (this.getUserToSelectRegion.length > 0) { - return this.getUserToSelectRegion[this.getUserToSelectRegion.length - 1] - } - else return null - } - - public popUserRegionSelectHandler = () => { - if (this.getUserToSelectRegion.length > 0) { - this.getUserToSelectRegion.pop() - this.getUserToSelectRegionUI$.next([...this.getUserToSelectRegion]) - } - } - - private s: Subscription[] = [] - - private onMouseClick(ev: any): boolean { - const { rs, spec } = this.getNextUserRegionSelectHandler() || {} - if (!!rs) { - - let moSegments: SapiRegionModel - this.store.pipe( - select(userInteraction.selectors.mousingOverRegions), - filter(val => val.length > 0), - map(val => val[0]), - take(1) - ).subscribe(val => moSegments = val) - - /** - * getROI api - */ - if (spec) { - /** - * if spec of overwrite click is for a point - */ - if (spec.type === EnumCustomRegion.POINT) { - this.popUserRegionSelectHandler() - let mousePositionReal - // rather than commiting mousePositionReal in state via action, do a single subscription instead. - // otherwise, the state gets updated way too often - if (window && (window as any).nehubaViewer) { - (window as any).nehubaViewer.mousePosition.inRealSpace - .take(1) - .subscribe(floatArr => { - mousePositionReal = floatArr && Array.from(floatArr).map((val: number) => val / 1e6) - }) - } - rs({ - type: spec.type, - payload: mousePositionReal - }) - return false - } - - /** - * if spec of overwrite click is for a point - */ - if (spec.type === EnumCustomRegion.PARCELLATION_REGION) { - - if (!!moSegments && Array.isArray(moSegments) && moSegments.length > 0) { - this.popUserRegionSelectHandler() - rs({ - type: spec.type, - payload: moSegments - }) - return false - } - } - } else { - /** - * selectARegion API - * TODO deprecate - */ - if (!!moSegments && Array.isArray(moSegments) && moSegments.length > 0) { - this.popUserRegionSelectHandler() - rs(moSegments[0]) - return false - } - } - } - return true - } - - constructor( - private store: Store<any>, - private dialogService: DialogService, - private snackbar: MatSnackBar, - private zone: NgZone, - private pluginService: PluginServices, - @Optional() @Inject(CANCELLABLE_DIALOG) openCancellableDialog: (message: string, options: any) => () => void, - @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) clickInterceptor: ClickInterceptor - ) { - if (clickInterceptor) { - const { register, deregister } = clickInterceptor - const onMouseClick = this.onMouseClick.bind(this) - register(onMouseClick) - this.onDestoryCb.push(() => deregister(onMouseClick)) - } - if (openCancellableDialog) { - this.s.push( - this.getUserToSelectRegionUI$.pipe( - distinctUntilChanged(), - switchMap(arr => { - if (this.dismissDialog) { - this.dismissDialog() - this.dismissDialog = null - } - - if (arr.length === 0) return of(null) - - const last = arr[arr.length - 1] - const { message, promise } = last - return race( - from(new Promise(resolve => { - this.dismissDialog = openCancellableDialog(message, { - userCancelCallback: () => { - resolve(last) - }, - ariaLabel: message - }) - })), - from(promise).pipe( - catchError(() => of(null)), - mapTo(null), - ) - ) - }) - ).subscribe(obj => { - if (obj) { - const { promise, rj } = obj - rj({ userInitiated: true }) - this.removeBasedOnPr(promise, { userInitiated: true }) - } - }) - ) - } - - this.selectParcellation$ = this.store.pipe( - select(atlasSelection.selectors.selectedParcellation), - shareReplay(1), - ) - - this.interactiveViewer = { - metadata : { - selectedTemplateBSubject : this.store.pipe( - select(atlasSelection.selectors.selectedTemplate), - shareReplay(1), - ), - - selectedParcellationBSubject : this.selectParcellation$, - - selectedRegionsBSubject : this.store.pipe( - select(atlasSelection.selectors.selectedRegions), - shareReplay(1), - ), - - get loadedTemplates(){ - throw new Error(`loadedTemplates is being deprecated`) - return [] - }, - - // TODO deprecate - regionsLabelIndexMap : new Map(), - - layersRegionLabelIndexMap: new Map(), - - }, - uiHandle : { - getModalHandler : () => { - throw new Error(`uihandle.getModalHandler has been deprecated`) - }, - - /* to be overwritten by atlasViewer.component.ts */ - getToastHandler : () => { - throw new Error('uiHandle.getToastHandler has been deprecated') - }, - - /** - * to be overwritten by atlas - */ - launchNewWidget: (manifest) => this.pluginService.launchNewWidget(manifest) - .then(() => { - // trigger change detection in Angular - // otherwise, model won't be updated until user input - - /* eslint-disable-next-line @typescript-eslint/no-empty-function */ - this.zone.run(() => { }) - }), - - getUserInput: config => this.dialogService.getUserInput(config) , - getUserConfirmation: config => this.dialogService.getUserConfirm(config), - - getUserToSelectARegion: message => { - console.warn(`interactiveViewer.uiHandle.getUserToSelectARegion is becoming deprecated. Use getUserToSelectRoi instead`) - const obj = { - message, - promise: null, - rs: null, - rj: null - } - const pr = new Promise((rs, rj) => { - obj.rs = rs - obj.rj = rj - }) - - obj.promise = pr - - this.getUserToSelectRegion.push(obj) - this.getUserToSelectRegionUI$.next([...this.getUserToSelectRegion]) - this.zone.run(() => { - - }) - return pr - }, - getUserToSelectRoi: (message: string, spec: ICustomRegionSpec) => { - if (!spec || !spec.type) throw new Error(`spec.type must be defined for getUserToSelectRoi`) - const obj = { - message, - spec, - promise: null, - rs: null, - rj: null - } - const pr = new Promise((rs, rj) => { - obj.rs = rs - obj.rj = rj - }) - - obj.promise = pr - - this.getUserToSelectRegion.push(obj) - this.getUserToSelectRegionUI$.next([...this.getUserToSelectRegion]) - this.zone.run(() => { - - }) - return pr - }, - - cancelPromise: pr => { - this.removeBasedOnPr(pr) - - this.zone.run(() => { }) - } - }, - pluginControl: new Proxy({}, { - get: (_, prop) => { - if (prop === 'loadExternalLibraries') return this.pluginService.loadExternalLibraries - if (prop === 'unloadExternalLibraries') return this.pluginService.unloadExternalLibraries - if (typeof prop === 'string') return this.pluginService.pluginHandlersMap.get(prop) - return undefined - } - }) as any, - } - window.interactiveViewer = this.interactiveViewer - this.init() - } - - private init() { - this.selectParcellation$.pipe( - filter(p => !!p && p.regions), - distinctUntilChanged() - ).subscribe(parcellation => { - // TODO rework plugin metadata - // this.interactiveViewer.metadata.regionsLabelIndexMap = getLabelIndexMap(parcellation.regions) - // this.interactiveViewer.metadata.layersRegionLabelIndexMap = getMultiNgIdsRegionsLabelIndexMap(parcellation) - }) - - this.s.push( - this.loadMesh$.subscribe(({ url, id, type, customFragmentColor = null }) => { - if (!this.interactiveViewer.viewerHandle) { - this.snackbar.open('No atlas loaded! Loading mesh failed!', 'Dismiss') - } - this.interactiveViewer.viewerHandle?.loadLayer({ - [id]: { - type: 'mesh', - source: `vtk://${url}`, - shader: `void main(){${customFragmentColor || FRAGMENT_EMIT_RED};}` - } - }) - }) - ) - } - - ngOnDestroy(){ - while (this.onDestoryCb.length > 0) this.onDestoryCb.pop()() - while(this.s.length > 0){ - this.s.pop().unsubscribe() - } - } -} - -export interface IInteractiveViewerInterface { - - metadata: { - selectedTemplateBSubject: Observable<any|null> - selectedParcellationBSubject: Observable<any|null> - selectedRegionsBSubject: Observable<any[]|null> - loadedTemplates: any[] - regionsLabelIndexMap: Map<number, any> | null - layersRegionLabelIndexMap: Map<string, Map<number, any>> - } - - viewerHandle?: IVIewerHandle - - uiHandle: { - getModalHandler: () => void - getToastHandler: () => void - launchNewWidget: (manifest: IPluginManifest) => Promise<any> - getUserInput: (config: IGetUserInputConfig) => Promise<string> - getUserConfirmation: (config: IGetUserConfirmation) => Promise<any> - getUserToSelectARegion: (selectingMessage: any) => Promise<any> - getUserToSelectRoi: (selectingMessage: string, spec?: ICustomRegionSpec) => Promise<any> - cancelPromise: (pr: Promise<any>) => void - } - - pluginControl: { - loadExternalLibraries: (libraries: string[]) => Promise<void> - unloadExternalLibraries: (libraries: string[]) => void - [key: string]: any - } -} - -interface IGetUserConfirmation { - title?: string - message?: string -} - -interface IGetUserInputConfig extends IGetUserConfirmation { - placeholder?: string - defaultValue?: string -} - -export interface IUserLandmark { - name: string - position: [number, number, number] - id: string /* probably use the it to track and remove user landmarks */ - highlight: boolean - color?: [number, number, number] -} - -export enum EnumCustomRegion{ - POINT = 'POINT', - PARCELLATION_REGION = 'PARCELLATION_REGION', -} - -export interface ICustomRegionSpec{ - type: string // type of EnumCustomRegion -} - -export interface IVIewerHandle { - - setNavigationLoc: (coordinates: [number, number, number], realSpace?: boolean) => void - moveToNavigationLoc: (coordinates: [number, number, number], realSpace?: boolean) => void - setNavigationOri: (quat: [number, number, number, number]) => void - moveToNavigationOri: (quat: [number, number, number, number]) => void - showSegment: (labelIndex: number) => void - hideSegment: (labelIndex: number) => void - showAllSegments: () => void - hideAllSegments: () => void - - getLayersSegmentColourMap: () => Map<string, Map<number, {red: number, green: number, blue: number}>> - - applyLayersColourMap: (newLayerColourMap: Map<string, Map<number, {red: number, green: number, blue: number}>>) => void - - loadLayer: (layerobj: any) => any - removeLayer: (condition: {name: string | RegExp}) => string[] - setLayerVisibility: (condition: {name: string|RegExp}, visible: boolean) => void - - add3DLandmarks: (landmarks: IUserLandmark[]) => void - remove3DLandmarks: (ids: string[]) => void - - mouseEvent: Observable<{eventName: string, event: MouseEvent}> - mouseOverNehuba: Observable<{labelIndex: number, foundRegion: any | null}> - mouseOverNehubaUI: Observable<{ annotation: any, landmark: any, customLandmark: any }> - getNgHash: () => string -} - -export type TSetViewerHandle = (viewerHandle: IVIewerHandle) => void - -export const API_SERVICE_SET_VIEWER_HANDLE_TOKEN = new InjectionToken<TSetViewerHandle>('API_SERVICE_SET_VIEWER_HANDLE_TOKEN') - -export const setViewerHandleFactory = (apiService: AtlasViewerAPIServices) => { - return (viewerHandle: IVIewerHandle) => apiService.interactiveViewer.viewerHandle = viewerHandle -} diff --git a/src/atlasViewer/atlasViewer.style.css b/src/atlasViewer/atlasViewer.style.css index 24a3efe3d..3bbc083c0 100644 --- a/src/atlasViewer/atlasViewer.style.css +++ b/src/atlasViewer/atlasViewer.style.css @@ -16,3 +16,22 @@ div.displayCard { opacity: 0.8; } + +.widget-canvas-container +{ + position: absolute; + top: 0; + left: 0; + z-index: 9; + width: 100%; + height: 100%; + pointer-events: none; +} +.widget-canvas +{ + position: absolute; + width: 0; + height: 0; + pointer-events: none; + overflow: visible; +} diff --git a/src/atlasViewer/atlasViewer.template.html b/src/atlasViewer/atlasViewer.template.html index e62408b36..ed2e2c6da 100644 --- a/src/atlasViewer/atlasViewer.template.html +++ b/src/atlasViewer/atlasViewer.template.html @@ -1,8 +1,3 @@ - -<!-- required for manufacturing plugin templates --> -<div pluginFactoryDirective> -</div> - <ng-container *ngIf="meetsRequirement; else doesNotMeetReqTemplate"> <ng-container *ngTemplateOutlet="viewerBody"> @@ -44,6 +39,9 @@ (iav-captureClickListenerDirective-onUnmovedClick)="mouseClickDocument($event)"> </iav-cmp-viewer-container> + <div class="widget-canvas-container"> + <div widget-canvas class="widget-canvas"></div> + </div> </div> </ng-template> diff --git a/src/extra_styles.css b/src/extra_styles.css index c4cd57121..14784d9e7 100644 --- a/src/extra_styles.css +++ b/src/extra_styles.css @@ -701,18 +701,6 @@ kg-dataset-previewer > img width: 100%; } -.hover-grab -{ - opacity: 0.5; - transition: opacity 200ms ease-in-out; - cursor: move; -} - -.hover-grab:hover -{ - opacity: 1.0; -} - .m-15 { margin: 15px; diff --git a/src/main.module.ts b/src/main.module.ts index bf993e757..1272d28f6 100644 --- a/src/main.module.ts +++ b/src/main.module.ts @@ -38,7 +38,7 @@ import { MessagingGlue } from './messagingGlue'; import { BS_ENDPOINT } from './util/constants'; import { QuickTourModule } from './ui/quickTour'; import { of } from 'rxjs'; -import { CANCELLABLE_DIALOG } from './util/interfaces'; +import { CANCELLABLE_DIALOG, CANCELLABLE_DIALOG_OPTS } from './util/interfaces'; import { environment } from 'src/environments/environment' import { NotSupportedCmp } from './notSupportedCmp/notSupported.component'; import { @@ -107,7 +107,7 @@ import { CONST } from "common/constants" { provide: CANCELLABLE_DIALOG, useFactory: (uiService: UIService) => { - return (message, option) => { + return (message: string, option: CANCELLABLE_DIALOG_OPTS) => { const actionBtn = { type: 'mat-stroked-button', color: 'default', diff --git a/src/messaging/service.ts b/src/messaging/service.ts index 7739108fc..7a1c1f7ff 100644 --- a/src/messaging/service.ts +++ b/src/messaging/service.ts @@ -7,7 +7,7 @@ import { getUuid } from "src/util/fn"; import { AtlasWorkerService } from "src/atlasViewer/atlasViewer.workerService.service"; import { ConfirmDialogComponent } from "src/components/confirmDialog/confirmDialog.component"; -import { IMessagingActions, IMessagingActionTmpl, ILoadMesh, LOAD_MESH_TOKEN, WINDOW_MESSAGING_HANDLER_TOKEN, IWindowMessaging } from './types' +import { IMessagingActions, IMessagingActionTmpl, WINDOW_MESSAGING_HANDLER_TOKEN, IWindowMessaging } from './types' import { TYPE as NMV_TYPE, processJsonLd as nmvProcess } from './nmvSwc/index' import { TYPE as NATIVE_TYPE, processJsonLd as nativeProcess } from './native' @@ -35,7 +35,6 @@ export class MessagingService { private snackbar: MatSnackBar, private worker: AtlasWorkerService, @Optional() @Inject(WINDOW_MESSAGING_HANDLER_TOKEN) private messagingHandler: IWindowMessaging, - @Optional() @Inject(LOAD_MESH_TOKEN) private loadMesh: (loadMeshParam: ILoadMesh) => void, ){ if (window.opener){ @@ -189,14 +188,17 @@ export class MessagingService { }) isLoadingSnack?.dismiss() const meshId = 'bobby' - if (this.loadMesh) { + if (false) { + /** + * TODO re-enable plotly VTK mesh + */ const { objectUrl, customFragmentColor } = resp.result || {} - this.loadMesh({ - type: 'VTK', - id: meshId, - url: objectUrl, - customFragmentColor - }) + // this.loadMesh({ + // type: 'VTK', + // id: meshId, + // url: objectUrl, + // customFragmentColor + // }) } else { this.snackbar.open(`Error: loadMesh method not injected.`) } diff --git a/src/messaging/types.ts b/src/messaging/types.ts index 0e7cc13eb..6eb83f851 100644 --- a/src/messaging/types.ts +++ b/src/messaging/types.ts @@ -45,14 +45,6 @@ export interface IMessagingActions<TAction extends keyof IMessagingActionTmpl> { payload: IMessagingActionTmpl[TAction] } -export interface ILoadMesh { - type: 'VTK' - id: string - url: string - customFragmentColor?: string -} -export const LOAD_MESH_TOKEN = new InjectionToken<(loadMeshParam: ILoadMesh) => void>('LOAD_MESH_TOKEN') - export interface IWindowMessaging { loadTempladById(payload: IMessagingActionTmpl['loadTemplate']): void loadResource(payload: IMessagingActionTmpl['loadResource']): void diff --git a/src/plugin/MIGRATION.md b/src/plugin/MIGRATION.md new file mode 100644 index 000000000..cb162d8d0 --- /dev/null +++ b/src/plugin/MIGRATION.md @@ -0,0 +1,31 @@ +# Migrate from siibra-explorer < 2.7.0 + +Plugin within siibra-explorer existed since before `pre-0.2.0`. We changed the way plugin works on `siibra-explorer==2.7.0`. + +## Why + +In siibra-explorer < 2.7.0, the HTML, JS are rendered directly in the same frame as siibra-explorer. + +Whilst this approach provided a lot of flexibility for the plugin, it also introduced a lot of points of failures and/or non-optimal practices. + +For example, the objects passed to the plugin was not always structureClone'd. This meant that plugins which mutate these objects could cause issues difficult to debug. + +Another example is that plugin authors often have to write HTML and JS specificially to interact with siibra-explorer. These code snippets often cannot be reused (since they expect a globally defined `interactiveViewer` object to exist.) + +Additionally, the previous system necessitates the running of arbitary JS code, which can be a security vulnerability. + +## The new system + +The plugin now runs in an iframe, and the data are passed between `siibra-explorer` and the plugin via [postMessage API](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage). This address all/most of the three concerns above: + +- objects passed will always be a clone (per `postMessage` spec). This allows plugin authors to do as their heart content with the received data, and it will not affect the viewer instance + +- plugin authors will provide a valid HTML (rather than HTML fragment). It can be rendered independently without `siibra-explorer`.[1] + +- any arbitary code from the plugin is sandboxed in the iframe, and should not interfere with `siibra-explorer`. This does **not** completely eliminate potential security threats: + + - Best practices still needs to be followed to harden the security (e.g. use `sandbox` attribute (WIP)) + + - Existing browser vulnerabilities, which the browser vendors have much greater resources and incentive to provide a fix. + +[1] Most modern browser are quite forgiving when it comes to rendering HTML. They could often render partial/invalid HTML. We still believe having spec compliant HTML is a good practice. diff --git a/src/plugin/README.md b/src/plugin/README.md new file mode 100644 index 000000000..260bd7db5 --- /dev/null +++ b/src/plugin/README.md @@ -0,0 +1,29 @@ +# Plugins + +:warning: the API in this document refer to `siibra-explorer>=2.7.0`. For migration guide/rationale, please see [MIGRATION.md](./MIGRATION.md) + +siibra-explorer provides a plugin system, which allow a third party application to interact with siibra-explorer. + +## Quickstart + +<!-- TBD --> + +## Architecture + +The plugin needs to provide a HTML page, served over HTTP. This will be embedded into siibra-explorer as an iframe. + +All communications between siibra-explorer and plugin will occur via the [postMessage API](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage). + +## Lifecycle + +`handshake.init` (up to 10x attempts, 1sec debounce) -> `{broadcast|request}` -> `handshake.exit` (NYI) + +Please note that the `handshake.init` needs to be responded to, *before* any other messages are sent. + +## API References + +[handshake API](./handshake.md) + +[broadcast API](./broadcast.md) + +[request API](./request.md) diff --git a/src/plugin/atlasViewer.pluginService.service.spec.ts b/src/plugin/atlasViewer.pluginService.service.spec.ts deleted file mode 100644 index 7735fcf8e..000000000 --- a/src/plugin/atlasViewer.pluginService.service.spec.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { CommonModule } from "@angular/common" -import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing" -import { fakeAsync, TestBed, tick } from "@angular/core/testing" -import { MockStore, provideMockStore } from "@ngrx/store/testing" -import { ComponentsModule } from "src/components" -import { DialogService } from "src/services/dialogService.service" -import { AngularMaterialModule } from "src/sharedModules" -import { userPreference } from "src/state" -import { APPEND_SCRIPT_TOKEN, REMOVE_SCRIPT_TOKEN } from "src/util/constants" -import { WidgetModule, WidgetServices } from "src/widget" -import { PluginServices } from "./atlasViewer.pluginService.service" -import { PluginModule } from "./plugin.module" - -const MOCK_PLUGIN_MANIFEST = { - name: 'fzj.xg.MOCK_PLUGIN_MANIFEST', - templateURL: 'http://localhost:10001/template.html', - scriptURL: 'http://localhost:10001/script.js', - version: '0.1.0' -} - -const spyfn = { - appendSrc: jasmine.createSpy('appendSrc') -} - - - -describe('> atlasViewer.pluginService.service.ts', () => { - describe('> PluginServices', () => { - - let pluginService: PluginServices - let httpMock: HttpTestingController - let mockStore: MockStore - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - AngularMaterialModule, - CommonModule, - WidgetModule, - PluginModule, - HttpClientTestingModule, - ComponentsModule, - ], - providers: [ - provideMockStore(), - PluginServices, - { - provide: APPEND_SCRIPT_TOKEN, - useValue: spyfn.appendSrc - }, - { - provide: REMOVE_SCRIPT_TOKEN, - useValue: () => Promise.resolve() - }, - { - provide: DialogService, - useValue: { - getUserConfirm: () => Promise.resolve() - } - }, - ] - }).compileComponents() - - httpMock = TestBed.inject(HttpTestingController) - pluginService = TestBed.inject(PluginServices) - mockStore = TestBed.inject(MockStore) - pluginService.pluginViewContainerRef = { - createComponent: () => { - return { - onDestroy: () => {}, - instance: { - elementRef: { - nativeElement: { - append: () => {} - } - } - } - } - } - } as any - - httpMock.expectOne('plugins/manifests').flush('[]') - - const widgetService = TestBed.inject(WidgetServices) - /** - * widget service floatingcontainer not inst in this circumstance - * TODO fix widget service tests importing widget service are not as flaky - */ - widgetService.addNewWidget = () => { - return {} as any - } - }) - - afterEach(() => { - spyfn.appendSrc.calls.reset() - const ctrl = TestBed.inject(HttpTestingController) - ctrl.verify() - }) - - it('> service can be inst', () => { - expect(pluginService).toBeTruthy() - }) - - it('expectOne is working as expected', done => { - - pluginService.fetch('test') - .then(text => { - expect(text).toEqual('bla') - done() - }) - httpMock.expectOne('test').flush('bla') - - }) - - /** - * need to consider user confirmation on csp etc - */ - describe('#launchPlugin', () => { - - beforeEach(() => { - mockStore.overrideSelector(userPreference.selectors.userCsp, {}) - }) - - describe('> basic fetching functionality', () => { - it('> fetches templateURL and scriptURL properly', fakeAsync(() => { - - pluginService.launchPlugin({...MOCK_PLUGIN_MANIFEST}) - - tick(100) - - const mockTemplate = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.templateURL) - mockTemplate.flush('hello world') - - tick(100) - - expect(spyfn.appendSrc).toHaveBeenCalledTimes(1) - expect(spyfn.appendSrc).toHaveBeenCalledWith(MOCK_PLUGIN_MANIFEST.scriptURL) - - })) - - it('> template overrides templateURL', fakeAsync(() => { - pluginService.launchPlugin({ - ...MOCK_PLUGIN_MANIFEST, - template: '' - }) - - tick(20) - httpMock.expectNone(MOCK_PLUGIN_MANIFEST.templateURL) - })) - - it('> script with scriptURL throws', done => { - pluginService.launchPlugin({ - ...MOCK_PLUGIN_MANIFEST, - script: '', - scriptURL: null - }) - .then(() => { - /** - * should not pass - */ - expect(true).toEqual(false) - }) - .catch(e => { - done() - }) - - /** - * http call will not be made, as rejection happens by Promise.reject, while fetch call probably happens at the next event cycle - */ - httpMock.expectNone(MOCK_PLUGIN_MANIFEST.templateURL) - }) - - describe('> user permission', () => { - let userConfirmSpy: jasmine.Spy - let readyPluginSpy: jasmine.Spy - let cspManifest = { - ...MOCK_PLUGIN_MANIFEST, - csp: { - 'connect-src': [`'unsafe-eval'`] - } - } - afterEach(() => { - userConfirmSpy.calls.reset() - readyPluginSpy.calls.reset() - }) - beforeEach(() => { - readyPluginSpy = spyOn(pluginService, 'readyPlugin').and.callFake(() => Promise.reject()) - const dialogService = TestBed.inject(DialogService) - userConfirmSpy = spyOn(dialogService, 'getUserConfirm') - }) - - describe('> if user permission has been given', () => { - beforeEach(fakeAsync(() => { - mockStore.overrideSelector(userPreference.selectors.userCsp, { [`${MOCK_PLUGIN_MANIFEST.name}::${MOCK_PLUGIN_MANIFEST.version}`]: {} }) - userConfirmSpy.and.callFake(() => Promise.reject()) - pluginService.launchPlugin({ - ...cspManifest - }).catch(() => { - /** - * expecting to throw because call fake returning promise.reject in beforeEach - */ - }) - tick(20) - })) - it('> will not ask for permission', () => { - expect(userConfirmSpy).not.toHaveBeenCalled() - }) - - it('> will call ready plugin', () => { - expect(readyPluginSpy).toHaveBeenCalled() - }) - }) - - describe('> if user permission has not yet been given', () => { - beforeEach(() => { - mockStore.overrideSelector(userPreference.selectors.userCsp, {}) - }) - describe('> user permission', () => { - beforeEach(fakeAsync(() => { - pluginService.launchPlugin({ - ...cspManifest - }).catch(() => { - /** - * expecting to throw because call fake returning promise.reject in beforeEach - */ - }) - tick(40) - })) - it('> will be asked for', () => { - expect(userConfirmSpy).toHaveBeenCalled() - }) - }) - - describe('> if user accepts', () => { - beforeEach(fakeAsync(() => { - userConfirmSpy.and.callFake(() => Promise.resolve()) - - pluginService.launchPlugin({ - ...cspManifest - }).catch(() => { - /** - * expecting to throw because call fake returning promise.reject in beforeEach - */ - }) - })) - it('> calls /POST user/pluginPermissions', () => { - httpMock.expectOne({ - method: 'POST', - url: 'user/pluginPermissions' - }) - }) - }) - - describe('> if user declines', () => { - - beforeEach(fakeAsync(() => { - userConfirmSpy.and.callFake(() => Promise.reject()) - - pluginService.launchPlugin({ - ...cspManifest - }).catch(() => { - /** - * expecting to throw because call fake returning promise.reject in beforeEach - */ - }) - })) - it('> calls /POST user/pluginPermissions', () => { - httpMock.expectNone({ - method: 'POST', - url: 'user/pluginPermissions' - }) - }) - }) - }) - }) - }) - - describe('> racing slow connection when launching plugin', () => { - it('> when template/script has yet been fetched, repeated launchPlugin should not result in repeated fetching', fakeAsync(() => { - - expect(pluginService.pluginIsLaunching(MOCK_PLUGIN_MANIFEST.name)).toBeFalsy() - expect(pluginService.pluginHasLaunched(MOCK_PLUGIN_MANIFEST.name)).toBeFalsy() - pluginService.launchPlugin({...MOCK_PLUGIN_MANIFEST}) - pluginService.launchPlugin({...MOCK_PLUGIN_MANIFEST}) - tick(20) - const req = httpMock.expectOne(MOCK_PLUGIN_MANIFEST.templateURL) - req.flush('baba') - tick(20) - expect(spyfn.appendSrc).toHaveBeenCalledTimes(1) - - expect( - pluginService.pluginIsLaunching(MOCK_PLUGIN_MANIFEST.name) || - pluginService.pluginHasLaunched(MOCK_PLUGIN_MANIFEST.name) - ).toBeTruthy() - })) - }) - - }) - }) -}) diff --git a/src/plugin/atlasViewer.pluginService.service.ts b/src/plugin/atlasViewer.pluginService.service.ts deleted file mode 100644 index 9dc142ad0..000000000 --- a/src/plugin/atlasViewer.pluginService.service.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { HttpClient } from '@angular/common/http' -import { ComponentFactory, ComponentFactoryResolver, Injectable, ViewContainerRef, Inject, SecurityContext } from "@angular/core"; -import { PluginUnit } from "./pluginUnit/pluginUnit.component"; -import { select, Store } from "@ngrx/store"; -import { BehaviorSubject, from, merge, Observable, of } from "rxjs"; -import { catchError, filter, map, mapTo, shareReplay, switchMap, switchMapTo, take, tap } from "rxjs/operators"; -import { LoggingService } from 'src/logging'; -import { WidgetUnit, WidgetServices } from "src/widget"; -import { APPEND_SCRIPT_TOKEN, REMOVE_SCRIPT_TOKEN, getHttpHeader } from 'src/util/constants'; -import { PluginFactoryDirective } from './pluginFactory.directive'; -import { DialogService } from 'src/services/dialogService.service'; -import { DomSanitizer } from '@angular/platform-browser'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { actions } from "src/state/plugins" -import { userPreference } from 'src/state'; - -const requiresReloadMd = `\n\n***\n\n**warning**: interactive atlas viewer **will** be reloaded in order for the change to take effect.` - -class PluginHandler { - public onShutdown: (callback: () => void) => void - public blink: (sec?: number) => void - public shutdown: () => void - - public initState?: any - public initStateUrl?: string - - public setInitManifestUrl: (url: string|null) => void - - public setProgressIndicator: (progress: number) => void -} - -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', -}) - -export class PluginServices { - - public pluginHandlersMap: Map<string, PluginHandler> = new Map() - - public loadExternalLibraries: (libraries: string[]) => Promise<any> = () => Promise.reject(`fail to overwritten`) - public unloadExternalLibraries: (libraries: string[]) => void = () => { throw new Error(`failed to be overwritten`) } - - public fetchedPluginManifests: IPluginManifest[] = [] - public pluginViewContainerRef: ViewContainerRef - - private pluginUnitFactory: ComponentFactory<PluginUnit> - public minimisedPlugins$: Observable<Set<string>> - - /** - * TODO remove polyfil and convert all calls to this.fetch to http client - */ - public fetch: (url: string, httpOption?: any) => Promise<any> = (url, httpOption = {}) => this.http.get(url, httpOption).toPromise() - - constructor( - private widgetService: WidgetServices, - private cfr: ComponentFactoryResolver, - private store: Store<any>, - private dialogService: DialogService, - private snackbar: MatSnackBar, - private http: HttpClient, - private log: LoggingService, - private sanitizer: DomSanitizer, - @Inject(APPEND_SCRIPT_TOKEN) private appendSrc: (src: string) => Promise<HTMLScriptElement>, - @Inject(REMOVE_SCRIPT_TOKEN) private removeSrc: (src: HTMLScriptElement) => void, - ) { - - this.pluginUnitFactory = this.cfr.resolveComponentFactory( PluginUnit ) - - /** - * TODO convert to rxjs streams, instead of Promise.all - */ - const pluginManifestsUrl = `plugins/manifests` - - this.http.get<IPluginManifest[]>(pluginManifestsUrl, { - responseType: 'json', - headers: getHttpHeader(), - }).subscribe( - arr => this.fetchedPluginManifests = arr, - this.log.error, - ) - - this.minimisedPlugins$ = merge( - of(new Set()), - this.widgetService.minimisedWindow$, - ).pipe( - map(set => { - const returnSet = new Set<string>() - for (const [pluginName, wu] of this.mapPluginNameToWidgetUnit) { - if (set.has(wu)) { - returnSet.add(pluginName) - } - } - return returnSet - }), - shareReplay(1), - ) - - this.launchedPlugins$ = new BehaviorSubject(new Set()) - } - - public launchNewWidget = (manifest) => this.launchPlugin(manifest) - .then(handler => { - this.orphanPlugins.add(manifest) - handler.onShutdown(() => { - this.orphanPlugins.delete(manifest) - }) - }) - - public readyPlugin(plugin: IPluginManifest): Promise<any> { - const isDefined = input => typeof input !== 'undefined' && input !== null - if (!isDefined(plugin.scriptURL)) { - return Promise.reject(`inline script has been deprecated. use scriptURL instead`) - } - if (isDefined(plugin.template)) { - return Promise.resolve() - } - if (plugin.templateURL) { - return this.fetch(plugin.templateURL, {responseType: 'text'}) - .then(template => { - plugin.template = template - }) - } - return Promise.reject('both template and templateURL are not defined') - } - - private launchedPlugins: Set<string> = new Set() - public launchedPlugins$: BehaviorSubject<Set<string>> - public pluginHasLaunched(pluginName: string) { - return this.launchedPlugins.has(pluginName) - } - public addPluginToLaunchedSet(pluginName: string) { - this.launchedPlugins.add(pluginName) - this.launchedPlugins$.next(this.launchedPlugins) - } - public removePluginFromLaunchedSet(pluginName: string) { - this.launchedPlugins.delete(pluginName) - this.launchedPlugins$.next(this.launchedPlugins) - } - - public pluginIsLaunching(pluginName: string) { - return this.launchingPlugins.has(pluginName) - } - public addPluginToIsLaunchingSet(pluginName: string) { - this.launchingPlugins.add(pluginName) - } - public removePluginFromIsLaunchingSet(pluginName: string) { - this.launchingPlugins.delete(pluginName) - } - - private mapPluginNameToWidgetUnit: Map<string, WidgetUnit> = new Map() - - public pluginIsMinimised(pluginName: string) { - return this.widgetService.isMinimised( this.mapPluginNameToWidgetUnit.get(pluginName) ) - } - - private launchingPlugins: Set<string> = new Set() - public orphanPlugins: Set<IPluginManifest> = new Set() - - public async revokePluginPermission(pluginKey: string) { - const createRevokeMd = (pluginKey: string) => `You are about to revoke the permission given to ${pluginKey}.${requiresReloadMd}` - - try { - await this.dialogService.getUserConfirm({ - markdown: createRevokeMd(pluginKey) - }) - - this.http.delete( - `user/pluginPermissions/${encodeURIComponent(pluginKey)}`, - { - headers: getHttpHeader() - } - ).subscribe( - () => { - window.location.reload() - }, - err => { - this.snackbar.open(`Error revoking plugin permission ${err.toString()}`, 'Dismiss') - } - ) - } catch (_e) { - /** - * user cancelled workflow - */ - } - } - - public async launchPlugin(plugin: IPluginManifest): Promise<PluginHandler> { - if (this.pluginIsLaunching(plugin.name)) { - // plugin launching please be patient - // TODO add visual feedback - return - } - if ( this.pluginHasLaunched(plugin.name)) { - // plugin launched - // TODO add visual feedback - - // if widget window is minimized, maximize it - - const wu = this.mapPluginNameToWidgetUnit.get(plugin.name) - if (this.widgetService.isMinimised(wu)) { - this.widgetService.unminimise(wu) - } else { - this.widgetService.minimise(wu) - } - return - } - - this.addPluginToIsLaunchingSet(plugin.name) - - const { csp, displayName, name = '', version = 'latest' } = plugin - const pluginKey = `${name}::${version}` - const createPermissionMd = ({ csp, name, version }) => { - const sanitize = val => this.sanitizer.sanitize(SecurityContext.HTML, val) - const getCspRow = ({ key }) => { - return `| ${sanitize(key)} | ${csp[key].map(v => '`' + sanitize(v) + '`').join(',')} |` - } - return `**${sanitize(displayName || name)}** version **${sanitize(version)}** requires additional permission from you to run:\n\n| permission | detail |\n| --- | --- |\n${Object.keys(csp).map(key => getCspRow({ key })).join('\n')}${requiresReloadMd}` - } - - await new Promise((rs, rj) => { - this.store.pipe( - select(userPreference.selectors.userCsp), - map(dict => !!dict[pluginKey]), - take(1), - switchMap(userAgreed => { - if (userAgreed) return of(true) - - /** - * check if csp exists - */ - if (!csp || Object.keys(csp).length === 0) { - return of(true) - } - /** - * TODO: check do not ask status - */ - return from( - this.dialogService.getUserConfirm({ - markdown: createPermissionMd({ csp, name, version }) - }) - ).pipe( - mapTo(true), - catchError(() => of(false)), - filter(v => !!v), - switchMapTo( - this.http.post(`user/pluginPermissions`, - { [pluginKey]: csp }, - { - responseType: 'json', - headers: getHttpHeader() - }) - ), - tap(() => { - window.location.reload() - }), - mapTo(false) - ) - }), - take(1), - ).subscribe( - val => val ? rs(null) : rj(`val is falsy`), - err => rj(err) - ) - }) - - await this.readyPlugin(plugin) - - /** - * catch when pluginViewContainerRef as not been overwritten? - */ - if (!this.pluginViewContainerRef) { - throw new Error(`pluginViewContainerRef not populated`) - } - const pluginUnit = this.pluginViewContainerRef.createComponent( this.pluginUnitFactory ) - /* TODO in v0.2, I used: - - const template = document.createElement('div') - template.insertAdjacentHTML('afterbegin',template) - - // reason was: - // changed from innerHTML to insertadjacenthtml to accomodate angular elements ... not too sure about the actual ramification - - */ - - const handler = new PluginHandler() - this.pluginHandlersMap.set(plugin.name, handler) - - /** - * define the handler properties prior to appending plugin script - * so that plugin script can access properties w/o timeout - */ - handler.initState = plugin.initState - ? plugin.initState - : null - - handler.initStateUrl = plugin.initStateUrl - ? plugin.initStateUrl - : null - - handler.setInitManifestUrl = url => this.store.dispatch( - actions.setInitMan({ - nameSpace: plugin.name, - url - }) - ) - - const shutdownCB = [ - () => { - this.removePluginFromLaunchedSet(plugin.name) - }, - ] - - handler.onShutdown = (cb) => { - if (typeof cb !== 'function') { - this.log.warn('onShutdown requires the argument to be a function') - return - } - shutdownCB.push(cb) - } - - const scriptEl = await this.appendSrc(plugin.scriptURL) - - handler.onShutdown(() => this.removeSrc(scriptEl)) - - const template = document.createElement('div') - template.insertAdjacentHTML('afterbegin', plugin.template) - pluginUnit.instance.elementRef.nativeElement.append( template ) - - const widgetCompRef = this.widgetService.addNewWidget(pluginUnit, { - state : 'floating', - exitable : true, - persistency: plugin.persistency, - title : plugin.displayName || plugin.name, - }) - - this.addPluginToLaunchedSet(plugin.name) - this.removePluginFromIsLaunchingSet(plugin.name) - - this.mapPluginNameToWidgetUnit.set(plugin.name, widgetCompRef.instance) - - const unsubscribeOnPluginDestroy = [] - - // TODO deprecate sec - handler.blink = (_sec?: number) => { - widgetCompRef.instance.blinkOn = true - } - - handler.setProgressIndicator = (val) => widgetCompRef.instance.progressIndicator = val - - handler.shutdown = () => { - widgetCompRef.instance.exit() - } - - handler.onShutdown(() => { - unsubscribeOnPluginDestroy.forEach(s => s.unsubscribe()) - this.pluginHandlersMap.delete(plugin.name) - this.mapPluginNameToWidgetUnit.delete(plugin.name) - }) - - pluginUnit.onDestroy(() => { - while (shutdownCB.length > 0) { - shutdownCB.pop()() - } - }) - - return handler - } - - public async addPluginViaManifestUrl(manifestUrl: string){ - try { - const json = await this.fetch(manifestUrl) - this.fetchedPluginManifests = [ - ...this.fetchedPluginManifests, - json - ] - } catch (e) { - throw new Error(e.statusText) - } - } -} - -export interface IPluginManifest { - name?: string - version?: string - displayName?: string - templateURL?: string - template?: string - scriptURL?: string - script?: string - initState?: any - initStateUrl?: string - persistency?: boolean - - description?: string - desc?: string - - homepage?: string - authors?: string - - csp?: { - 'connect-src'?: string[] - 'script-src'?: string[] - } -} diff --git a/src/plugin/broadcast.md b/src/plugin/broadcast.md new file mode 100644 index 000000000..bcc96cd4a --- /dev/null +++ b/src/plugin/broadcast.md @@ -0,0 +1,64 @@ +# Broadcasting API + +Broadcasting messages are sent under two circumstances: + +- the state of the viewer changed, initiated by any source (user, plugin etc). Sent to all active plugin clients. + +- immediately after the plugin client acknowledged `handshake.init` to the specific client. This is so that the client can get the current state of the viewer. + +Broadcasting messages never expects a response (and thus will never contain and `id` attribute) + +<!-- the API reference below are auto generated by generateTypes.js --> +<!-- do not edit, as the edit will be overwritten by the auto generation --> + +## API + +### `sxplr.on.atlasSelected` + +- payload + + ```ts + SapiAtlasModel + ``` + + + +### `sxplr.on.templateSelected` + +- payload + + ```ts + SapiSpaceModel + ``` + + + +### `sxplr.on.parcellationSelected` + +- payload + + ```ts + SapiParcellationModel + ``` + + + +### `sxplr.on.allRegions` + +- payload + + ```ts + SapiRegionModel[] + ``` + + + +### `sxplr.on.regionsSelected` + +- payload + + ```ts + SapiRegionModel[] + ``` + + diff --git a/src/plugin/const.ts b/src/plugin/const.ts new file mode 100644 index 000000000..68104badf --- /dev/null +++ b/src/plugin/const.ts @@ -0,0 +1,12 @@ +const PLUGIN_SRC_KEY = "x-plugin-portal-src" + +export function setPluginSrc(src: string, record: Record<string, unknown> = {}){ + return { + ...record, + [PLUGIN_SRC_KEY]: src + } +} + +export function getPluginSrc(record: Record<string, string> = {}){ + return record[PLUGIN_SRC_KEY] +} diff --git a/src/plugin/generateTypes.js b/src/plugin/generateTypes.js new file mode 100644 index 000000000..52f69d8f9 --- /dev/null +++ b/src/plugin/generateTypes.js @@ -0,0 +1,96 @@ +const ts = require('typescript') +const fs = require('fs') +const path = require('path') +const { promisify } = require('util') +const asyncReadFile = promisify(fs.readFile) +const asyncWriteFile = promisify(fs.writeFile) +const { processTypeAliasDeclaration, processRequestTypeAlias } = require('./tsUtil') + + +const typeAliasDeclarationMap = new Map() +const pathToApiService = path.join(__dirname, '../api/service.ts') +const NAMESPACE = `sxplr` +const filenames = { + handshake: path.join(__dirname, './handshake.md'), + broadcast: path.join(__dirname, './broadcast.md'), + request: path.join(__dirname, './request.md'), +} + +const puplateBroadCast = async broadcastNode => { + + if (!broadcastNode) throw new Error(`broadcastNode must be passed!`) + + const src = await asyncReadFile(filenames.broadcast, 'utf-8') + const output = processTypeAliasDeclaration(broadcastNode) + + let outputText = `` + for (const key in output) { + outputText += ` + +### \`${NAMESPACE}.on.${key}\` + +- payload + + \`\`\`ts + ${output[key]} + \`\`\` + +` + } + const newData = src.replace(/## API(.|\n)+/, s => `## API${outputText}\n`) + + await asyncWriteFile(filenames.broadcast, newData, 'utf-8') +} + +const populateConversations = async (filename, node) => { + const src = await asyncReadFile(filename, 'utf-8') + const output = processRequestTypeAlias(node, typeAliasDeclarationMap) + + let outputText = `` + for (const key in output) { + outputText += ` +### \`${NAMESPACE}.${key}\` + +- request + + \`\`\`ts + ${output[key]['request']} + \`\`\` + +- response + + \`\`\`ts + ${output[key]['response']} + \`\`\` + +` + } + const newData = src.replace(/## API(.|\n)+/, s => `## API${outputText}`) + await asyncWriteFile(filename, newData, 'utf-8') +} + +const main = async () => { + const src = await asyncReadFile(pathToApiService, 'utf-8') + const node = ts.createSourceFile( + './x.ts', + src, + ts.ScriptTarget.Latest + ) + node.forEachChild(n => { + if (ts.SyntaxKind[n.kind] === "TypeAliasDeclaration") { + typeAliasDeclarationMap.set(n.name?.text, n) + } + if (n.name?.text === "BroadCastingApiEvents") { + puplateBroadCast(n) + } + if (n.name?.text === "HeartbeatEvents") { + populateConversations(filenames.handshake, n) + } + if (n.name?.text === "ApiBoothEvents") { + populateConversations(filenames.request, n) + } + }) + +} + +main() diff --git a/src/plugin/pluginCsp/pluginCsp.style.css b/src/plugin/handshake.md similarity index 100% rename from src/plugin/pluginCsp/pluginCsp.style.css rename to src/plugin/handshake.md diff --git a/src/plugin/iframeSrc.pipe.ts b/src/plugin/iframeSrc.pipe.ts new file mode 100644 index 000000000..0bc0916b5 --- /dev/null +++ b/src/plugin/iframeSrc.pipe.ts @@ -0,0 +1,23 @@ +import { Pipe, PipeTransform, SecurityContext } from "@angular/core"; +import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; + +@Pipe({ + name: 'iframeSrc', + pure: true +}) + +export class IFrameSrcPipe implements PipeTransform { + constructor(private domSanitizer: DomSanitizer){} + + transform(src: string): SafeResourceUrl { + // https://angular.io/guide/security#sanitization-and-security-contexts + // Sanitizing resource url isn't possible + // hence bypassing + return this.domSanitizer.bypassSecurityTrustResourceUrl( + this.domSanitizer.sanitize( + SecurityContext.URL, + src + ) + ) + } +} diff --git a/src/plugin/index.ts b/src/plugin/index.ts index bae5875b5..480c8fb59 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,9 +1,3 @@ -export { - IPluginManifest, - PluginServices, - registerPluginFactoryDirectiveFactory, -} from './atlasViewer.pluginService.service' - export { PluginModule } from './plugin.module' \ No newline at end of file diff --git a/src/plugin/plugin.module.ts b/src/plugin/plugin.module.ts index ae859bab6..08d54d85d 100644 --- a/src/plugin/plugin.module.ts +++ b/src/plugin/plugin.module.ts @@ -4,11 +4,10 @@ import { LoggingModule } from "src/logging"; import { AngularMaterialModule } from "src/sharedModules"; import { UtilModule } from "src/util"; import { appendScriptFactory, APPEND_SCRIPT_TOKEN, removeScriptFactory, REMOVE_SCRIPT_TOKEN } from "src/util/constants"; -import { PluginServices, registerPluginFactoryDirectiveFactory } from "./atlasViewer.pluginService.service"; +import { IFrameSrcPipe } from "./iframeSrc.pipe"; import { PluginBannerUI } from "./pluginBanner/pluginBanner.component"; -import { PluginCspCtrlCmp } from "./pluginCsp/pluginCsp.component"; -import { PluginFactoryDirective, REGISTER_PLUGIN_FACTORY_DIRECTIVE } from "./pluginFactory.directive"; -import { PluginUnit } from "./pluginUnit/pluginUnit.component"; +import { PluginPortal } from "./pluginPortal/pluginPortal.component"; + @NgModule({ imports: [ @@ -18,25 +17,14 @@ import { PluginUnit } from "./pluginUnit/pluginUnit.component"; AngularMaterialModule, ], declarations: [ - PluginCspCtrlCmp, - PluginUnit, - PluginFactoryDirective, PluginBannerUI, + PluginPortal, + IFrameSrcPipe, ], exports: [ - PluginCspCtrlCmp, PluginBannerUI, - PluginUnit, - PluginFactoryDirective, ], providers: [ - - PluginServices, - { - provide: REGISTER_PLUGIN_FACTORY_DIRECTIVE, - useFactory: registerPluginFactoryDirectiveFactory, - deps: [ PluginServices ] - }, { provide: APPEND_SCRIPT_TOKEN, useFactory: appendScriptFactory, diff --git a/src/plugin/pluginBanner/pluginBanner.component.ts b/src/plugin/pluginBanner/pluginBanner.component.ts index 689f0aa32..e6dac94af 100644 --- a/src/plugin/pluginBanner/pluginBanner.component.ts +++ b/src/plugin/pluginBanner/pluginBanner.component.ts @@ -1,8 +1,9 @@ import { Component, ViewChild, TemplateRef } from "@angular/core"; -import { IPluginManifest, PluginServices } from "../atlasViewer.pluginService.service"; import { MatDialog } from "@angular/material/dialog"; import { environment } from 'src/environments/environment'; import { MatSnackBar } from "@angular/material/snack-bar"; +import { PluginService } from "../service"; +import { PluginManifest } from "../types"; @Component({ selector : 'plugin-banner', @@ -16,28 +17,17 @@ export class PluginBannerUI { EXPERIMENTAL_FEATURE_FLAG = environment.EXPERIMENTAL_FEATURE_FLAG - @ViewChild('pluginInfoTmpl', { read: TemplateRef }) - private pluginInfoTmpl: TemplateRef<any> + pluginManifests: PluginManifest[] = [] constructor( - public pluginServices: PluginServices, + private svc: PluginService, private matDialog: MatDialog, private matSnackbar: MatSnackBar, ) { } - public clickPlugin(plugin: IPluginManifest) { - this.pluginServices.launchPlugin(plugin) - } - - public showPluginInfo(manifest: IPluginManifest){ - this.matDialog.open( - this.pluginInfoTmpl, - { - data: manifest, - ariaLabel: `Additional information about a plugin` - } - ) + public launchPlugin(plugin: PluginManifest) { + this.svc.launchPlugin(plugin.url) } public showTmpl(tmpl: TemplateRef<any>){ @@ -47,20 +37,11 @@ export class PluginBannerUI { } public loadingThirdpartyPlugin = false - public async addThirdPartyPlugin(manifestUrl: string) { - this.loadingThirdpartyPlugin = true - try { - await this.pluginServices.addPluginViaManifestUrl(manifestUrl) - this.loadingThirdpartyPlugin = false - this.matSnackbar.open(`Adding plugin successful`, 'Dismiss', { - duration: 5000 - }) - } catch (e) { - this.loadingThirdpartyPlugin = false - this.matSnackbar.open(`Error adding plugin: ${e.toString()}`, 'Dismiss', { - duration: 5000 - }) - } + this.matSnackbar.open(`Adding third party plugin is current unavailable.`) + } + + test(){ + this.svc.launchPlugin('http://localhost:8000') } } diff --git a/src/plugin/pluginBanner/pluginBanner.template.html b/src/plugin/pluginBanner/pluginBanner.template.html index 3e452612e..80ffd873c 100644 --- a/src/plugin/pluginBanner/pluginBanner.template.html +++ b/src/plugin/pluginBanner/pluginBanner.template.html @@ -1,21 +1,16 @@ <mat-action-list> <button mat-menu-item - *ngFor="let plugin of pluginServices.fetchedPluginManifests" - [matTooltip]="plugin.displayName ? plugin.displayName : plugin.name" - (click)="clickPlugin(plugin)"> - <span mat-icon-button - aria-label="About this plugin" - class="mat-icon d-inline-flex align-items-center justify-content-center fa-stack" - (click)="showPluginInfo(plugin)" - iav-stop="click mousedown mouseup"> - <i class="fas fa-cube fa-stack-1x"></i> - <i class="fas fa-info-circle fa-stack-1x sub"></i> - </span> + *ngFor="let plugin of pluginManifests" + (click)="launchPlugin(plugin)"> <span> - {{ plugin.displayName ? plugin.displayName : plugin.name }} + {{ plugin.name }} </span> </button> + <button *ngIf="EXPERIMENTAL_FEATURE_FLAG" mat-menu-item (click)="test()"> + test + </button> + <button mat-menu-item *ngIf="EXPERIMENTAL_FEATURE_FLAG" (click)="showTmpl(thirdPartyPluginTmpl)"> <span> @@ -54,57 +49,3 @@ </mat-dialog-actions> </ng-template> - -<ng-template #pluginInfoTmpl let-manifest> - <h1 mat-dialog-title> - About {{ manifest.displayName || manifest.name }} - </h1> - - <div mat-dialog-content> - <mat-list> - <mat-list-item> - <span mat-list-icon class="d-inline-flex justify-content-center align-items-center"> - <i class="fas fa-info"></i> - </span> - <div mat-line> - Description - </div> - <div mat-line> - {{ manifest.description || manifest.desc || 'Not provided.' }} - </div> - </mat-list-item> - - <mat-list-item> - <span mat-list-icon class="d-inline-flex justify-content-center align-items-center"> - <i class="fas fa-users"></i> - </span> - <div mat-line> - Authors - </div> - <div mat-line> - {{ manifest.authors || 'Not provided' }} - </div> - </mat-list-item> - - <mat-list-item> - <span mat-list-icon class="d-inline-flex justify-content-center align-items-center"> - <i class="fas fa-globe-europe"></i> - </span> - <div mat-line> - Homepage - </div> - <div mat-line> - {{ manifest.homepage || 'Not provided' }} - </div> - </mat-list-item> - </mat-list> - - </div> - - <div mat-dialog-actions class="d-flex justify-content-center"> - <button mat-button mat-dialog-close> - close - </button> - </div> - -</ng-template> diff --git a/src/plugin/pluginCsp/pluginCsp.component.ts b/src/plugin/pluginCsp/pluginCsp.component.ts deleted file mode 100644 index ff1026172..000000000 --- a/src/plugin/pluginCsp/pluginCsp.component.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Component } from "@angular/core"; -import { select, Store } from "@ngrx/store"; -import { map } from "rxjs/operators"; -import { PluginServices } from "../atlasViewer.pluginService.service"; -import { userPreference } from "src/state" - -@Component({ - selector: 'plugin-csp-controller', - templateUrl: './pluginCsp.template.html', - styleUrls: [ - './pluginCsp.style.css' - ] -}) - -export class PluginCspCtrlCmp{ - - public pluginCsp$ = this.store$.pipe( - select(userPreference.selectors.userCsp), - map(pluginCsp => Object.keys(pluginCsp).map(key => ({ pluginKey: key, pluginCsp: pluginCsp[key] }))), - ) - - constructor( - private store$: Store<any>, - private pluginService: PluginServices, - ){ - - } - - revoke(pluginKey: string){ - this.pluginService.revokePluginPermission(pluginKey) - } -} \ No newline at end of file diff --git a/src/plugin/pluginCsp/pluginCsp.template.html b/src/plugin/pluginCsp/pluginCsp.template.html deleted file mode 100644 index 16159fa0b..000000000 --- a/src/plugin/pluginCsp/pluginCsp.template.html +++ /dev/null @@ -1,52 +0,0 @@ - -<ng-container *ngIf="pluginCsp$ | async as pluginsCsp; else fallbackTmpl"> - - <ng-template #pluginsCspContainerTmpl> - <ng-container *ngTemplateOutlet="pluginCpTmpl; context: { pluginsCsp: pluginsCsp }"> - </ng-container> - </ng-template> - - <ng-container *ngIf="pluginsCsp.length === 0; else pluginsCspContainerTmpl"> - <ng-container *ngTemplateOutlet="fallbackTmpl"> - </ng-container> - </ng-container> -</ng-container> - -<ng-template #fallbackTmpl> - You have not granted permission to any plugins. -</ng-template> - -<ng-template #pluginCpTmpl let-pluginsCsp="pluginsCsp"> - <p> - You have granted permission to the following plugins - </p> - - <mat-accordion> - <mat-expansion-panel *ngFor="let pluginCsp of pluginCsp$ | async"> - <mat-expansion-panel-header> - <mat-panel-title> - {{ pluginCsp['pluginKey'] }} - </mat-panel-title> - </mat-expansion-panel-header> - - <button mat-raised-button - color="warn" - (click)="revoke(pluginCsp['pluginKey'])"> - Revoke - </button> - - <mat-list> - <ng-container *ngFor="let csp of pluginCsp['pluginCsp'] | keyvalue"> - <span mat-subheader> - {{ csp['key'] }} - </span> - <mat-list-item *ngFor="let item of csp['value']"> - {{ item }} - </mat-list-item> - </ng-container> - </mat-list> - - </mat-expansion-panel> - </mat-accordion> - -</ng-template> diff --git a/src/plugin/pluginFactory.directive.spec.ts b/src/plugin/pluginFactory.directive.spec.ts deleted file mode 100644 index 29d723d97..000000000 --- a/src/plugin/pluginFactory.directive.spec.ts +++ /dev/null @@ -1,133 +0,0 @@ -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/plugin/pluginFactory.directive.ts b/src/plugin/pluginFactory.directive.ts deleted file mode 100644 index fcd476541..000000000 --- a/src/plugin/pluginFactory.directive.ts +++ /dev/null @@ -1,102 +0,0 @@ -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'], - ['6.2.0', 'https://cdnjs.cloudflare.com/ajax/libs/d3/6.2.0/d3.min.js'] - ])], - ['mathjax', new Map([ - ['3.1.2', 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.1.2/es5/tex-svg.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]', -}) - -export class PluginFactoryDirective { - constructor( - 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, - ) { - if (registerPluginFactoryDirective) { - registerPluginFactoryDirective(this) - } - } - - private loadedLibraries: Map<string, {counter: number, srcEl: HTMLScriptElement|null}> = new Map() - - async loadExternalLibraries(libraries: string[]) { - const libsToBeLoaded = libraries.map(libName => { - return { - libName, - libSrc: parseLibrary(libName), - } - }) - - for (const libToBeLoaded of libsToBeLoaded) { - - const { libSrc, libName } = libToBeLoaded - - // 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[]) { - 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/plugin/pluginPortal/pluginPortal.component.ts b/src/plugin/pluginPortal/pluginPortal.component.ts new file mode 100644 index 000000000..ce0da6086 --- /dev/null +++ b/src/plugin/pluginPortal/pluginPortal.component.ts @@ -0,0 +1,147 @@ +import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, Inject, OnDestroy, Optional, ViewChild, ViewContainerRef } from "@angular/core"; +import { combineLatest, fromEvent, interval, Subscription } from "rxjs"; +import { map, scan, share, startWith, take, filter } from "rxjs/operators"; +import { BoothVisitor, JRPCRequest, JRPCSuccessResp, ListenerChannel } from "src/api/jsonrpc"; +import { ApiBoothEvents, ApiService, BroadCastingApiEvents, HeartbeatEvents, namespace } from "src/api/service"; +import { getUuid } from "src/util/fn"; +import { WIDGET_PORTAL_TOKEN } from "src/widget/constants"; +import { getPluginSrc } from "../const"; +import { PluginService } from "../service"; + +@Component({ + selector: 'sxplr-plugin-portal', + template: ` + <iframe [src]="src | iframeSrc" [sandbox]="sandbox" #iframe> + </iframe> + `, + styles: [ + `:host { width: 100%; height: 100%; display: block; } iframe { width: 100%; height: 100%; border: none; }` + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) + +export class PluginPortal implements AfterViewInit, OnDestroy, ListenerChannel{ + + sandbox = [ + "allow-downloads", + "allow-popups", + "allow-popups-to-escape-sandbox", + "allow-scripts", + ].join(" ") + + @ViewChild('iframe', { read: ElementRef }) + iframeElRef: ElementRef + src: string + srcName: string + private origin: string + + private onDestroyCb: (() => void)[] = [] + private sub: Subscription[] = [] + private handshakeSub: Subscription[] = [] + + private boothVisitor: BoothVisitor<ApiBoothEvents> + private childWindow: Window + + constructor( + private apiService: ApiService, + private pluginSvc: PluginService, + public vcr: ViewContainerRef, + @Optional() @Inject(WIDGET_PORTAL_TOKEN) portalData: Record<string, string> + ){ + if (portalData){ + this.src = getPluginSrc(portalData) + const url = new URL(this.src) + this.origin = url.origin + } + } + + ngAfterViewInit(): void { + if (this.iframeElRef) { + const iframeWindow = (this.iframeElRef.nativeElement as HTMLIFrameElement).contentWindow + const handShake$ = interval(1000).pipe( + map(() => getUuid()), + take(10), + share() + ) + this.handshakeSub.push( + /** + * handshake + */ + handShake$.pipe( + // try for 10 seconds. If nothing loads within 10 minutes, assuming dead. + ).subscribe(id => { + const handshakeMsg: JRPCRequest<string, null> = { + jsonrpc: '2.0', + id, + method: `${namespace}.init`, + } + iframeWindow.postMessage(handshakeMsg, this.origin) + }), + + combineLatest([ + handShake$.pipe( + scan((acc, curr) => [...acc, curr], []) + ), + fromEvent<MessageEvent>(window, 'message').pipe( + startWith(null as MessageEvent), + ) + ]).subscribe(([ids, event]) => { + const { id, jsonrpc } = event?.data || {} + if (jsonrpc === "2.0" && ids.includes(id)) { + const data = event.data as JRPCSuccessResp<HeartbeatEvents['init']['response']> + + this.srcName = data.result.name || 'Untitled Pluging' + this.pluginSvc.setPluginName(this, this.srcName) + + while (this.handshakeSub.length > 0) this.handshakeSub.pop().unsubscribe() + + /** + * hook up to the listener for the plugin + */ + this.childWindow = iframeWindow + this.apiService.broadcastCh.addListener(this) + this.boothVisitor = this.apiService.booth.handshake() + } + }) + ) + + /** + * listening to plugin requests + * only start after boothVisitor is defined + */ + this.sub.push( + fromEvent<MessageEvent>(window, 'message').pipe( + startWith(null as MessageEvent), + filter(msg => !!this.boothVisitor && msg.data.jsonrpc === "2.0" && !!msg.data.method) + ).subscribe(async msg => { + try { + const result = await this.boothVisitor.request(msg.data) + this.childWindow.postMessage(result, this.origin) + } catch (e) { + this.childWindow.postMessage({ + id: msg.data.id, + error: { + code: -32603, + message: e.toString() + } + }, this.origin) + } + }) + ) + } + } + notify(payload: JRPCRequest<keyof BroadCastingApiEvents, BroadCastingApiEvents[keyof BroadCastingApiEvents]>) { + if (this.childWindow) { + this.childWindow.postMessage(payload, this.origin) + } + } + registerLeaveCb(cb: () => void) { + this.onDestroyCb.push(() => cb()) + } + + ngOnDestroy(): void { + while (this.handshakeSub.length > 0) this.handshakeSub.pop().unsubscribe() + while (this.sub.length > 0) this.sub.pop().unsubscribe() + while (this.onDestroyCb.length > 0) this.onDestroyCb.pop()() + } +} diff --git a/src/plugin/pluginUnit/pluginUnit.component.ts b/src/plugin/pluginUnit/pluginUnit.component.ts deleted file mode 100644 index 902701b16..000000000 --- a/src/plugin/pluginUnit/pluginUnit.component.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Component, ElementRef, HostBinding } from "@angular/core"; - -@Component({ - templateUrl : `./pluginUnit.template.html`, -}) - -export class PluginUnit { - - @HostBinding('attr.pluginContainer') - public pluginContainer = true - - constructor(public elementRef: ElementRef) { - - } -} diff --git a/src/plugin/pluginUnit/pluginUnit.template.html b/src/plugin/pluginUnit/pluginUnit.template.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/plugin/request.md b/src/plugin/request.md new file mode 100644 index 000000000..6260c2ae2 --- /dev/null +++ b/src/plugin/request.md @@ -0,0 +1,142 @@ +# Request API + +TBD + +<!-- the API reference below are auto generated by generateTypes.js --> +<!-- do not edit, as the edit will be overwritten by the auto generation --> + +## API +### `sxplr.getAllAtlases` + +- request + + ```ts + null + ``` + +- response + + ```ts + SapiAtlasModel[] + ``` + + +### `sxplr.getSupportedTemplates` + +- request + + ```ts + null + ``` + +- response + + ```ts + SapiSpaceModel[] + ``` + + +### `sxplr.getSupportedParcellations` + +- request + + ```ts + null + ``` + +- response + + ```ts + SapiParcellationModel[] + ``` + + +### `sxplr.selectAtlas` + +- request + + ```ts + {"@id": string} + ``` + +- response + + ```ts + 'OK' + ``` + + +### `sxplr.selectParcellation` + +- request + + ```ts + {"@id": string} + ``` + +- response + + ```ts + 'OK' + ``` + + +### `sxplr.selectTemplate` + +- request + + ```ts + {"@id": string} + ``` + +- response + + ```ts + 'OK' + ``` + + +### `sxplr.navigateTo` + +- request + + ```ts + MainState['[state.atlasSelection]']['navigation'] & {"animate": boolean} + ``` + +- response + + ```ts + 'OK' + ``` + + +### `sxplr.getUserToSelectARoi` + +- request + + ```ts + {"type": 'region' | 'point', "message": string} + ``` + +- response + + ```ts + SapiRegionModel | OpenMINDSCoordinatePoint + ``` + + +### `sxplr.cancelRequest` + +- request + + ```ts + {"id": string} + ``` + +- response + + ```ts + 'OK' + ``` + diff --git a/src/plugin/service.ts b/src/plugin/service.ts new file mode 100644 index 000000000..97a72e542 --- /dev/null +++ b/src/plugin/service.ts @@ -0,0 +1,57 @@ +import { Injectable, Injector, NgZone } from "@angular/core"; +import { WIDGET_PORTAL_TOKEN } from "src/widget/constants"; +import { WidgetService } from "src/widget/service"; +import { WidgetPortal } from "src/widget/widgetPortal/widgetPortal.component"; +import { setPluginSrc } from "./const"; +import { PluginPortal } from "./pluginPortal/pluginPortal.component"; + +@Injectable({ + providedIn: 'root' +}) +export class PluginService { + loadedPlugins: string[] = [] + srcWidgetMap = new Map<string, WidgetPortal<PluginPortal>>() + + constructor( + private wSvc: WidgetService, + private injector: Injector, + private zone: NgZone, + ){} + + + async launchPlugin(htmlSrc: string){ + if (this.loadedPlugins.includes(htmlSrc)) return + const injector = Injector.create({ + providers: [{ + provide: WIDGET_PORTAL_TOKEN, + useValue: setPluginSrc(htmlSrc, {}) + }], + parent: this.injector + }) + const wdg = this.wSvc.addNewWidget(PluginPortal, injector) + this.srcWidgetMap.set(htmlSrc, wdg) + } + + setPluginName(plg: PluginPortal, name: string) { + + if (!this.srcWidgetMap.has(plg.src)) { + console.warn(`cannot find plg.src ${plg.src}`) + return + } + const wdg = this.srcWidgetMap.get(plg.src) + this.zone.run(() => wdg.name = name) + } + + rmPlugin(plg: PluginPortal){ + this.loadedPlugins = this.loadedPlugins.filter(plgSrc => plgSrc !== plg.src) + + if (!this.srcWidgetMap.has(plg.src)) { + console.warn(`cannot find plg.src ${plg.src}`) + return + } + const wdg = this.srcWidgetMap.get(plg.src) + this.srcWidgetMap.delete(plg.src) + + this.wSvc.rmWidget(wdg) + } +} diff --git a/src/plugin/tsUtil.js b/src/plugin/tsUtil.js new file mode 100644 index 000000000..b27c5c973 --- /dev/null +++ b/src/plugin/tsUtil.js @@ -0,0 +1,129 @@ +const ts = require('typescript') + + +function processIndexAccessor(mem) { + if (ts.SyntaxKind[mem.kind] !== "IndexedAccessType") { + throw new Error(`Index accessor needs to have index accessor type as mem.kind`) + } + return `${getTypeText(mem.objectType)}[${getTypeText(mem.indexType)}]` +} + +function getTypeText(node){ + switch(ts.SyntaxKind[node.kind]) { + case "TypeReference": { + return node.typeName.text + } + case "ArrayType": { + return `${node.elementType.typeName.text}[]` + } + case "TypeLiteral": { + let returnVal = '{' + returnVal += node.members.map(mem => `"${mem.name.text}": ${getTypeText(mem.type)}`).join(', ') + returnVal += "}" + return returnVal + } + case "LiteralType": { + if (ts.SyntaxKind[node.literal.kind] === "NullKeyword"){ + return `null` + } + if (ts.SyntaxKind[node.literal.kind] === "StringLiteral"){ + return `'${node.literal.text}'` + } + throw new Error(`LiteralType not caught`) + } + case "BooleanKeyword": { + return `boolean` + } + case "StringKeyword": { + return `string` + } + case "IntersectionType": { + return node.types.map(getTypeText).join(' & ') + } + case "UnionType": { + return node.types.map(getTypeText).join(' | ') + } + case 'PropertySignature': { + return processPropertySignature(node) + } + case 'IndexedAccessType': { + return processIndexAccessor(node) + } + default: { + debugger + throw new Error(`No parser for type ${ts.SyntaxKind[node.kind]}`) + } + } +} + +function processPropertySignature(node) { + const output = {} + debugger +} + +function processNodeMember(mem, typeAliasDeclarationMap = new Map()) { + if (ts.SyntaxKind[mem.kind] === "IndexedAccessType") { + return processIndexAccessor(mem) + } + if (ts.SyntaxKind[mem.kind] === "TypeReference") { + return mem.typeName.text + } + if (ts.SyntaxKind[mem.kind] !== "PropertySignature") { + throw new Error(`mem.kind should be of PropertySignature, but is instead ${ts.SyntaxKind[mem.kind]}`) + } + const typeText = getTypeText(mem.type) + if (typeAliasDeclarationMap.has(typeText)) { + return getTypeText(typeAliasDeclarationMap.get(typeText).type) + } + return typeText +} + +function processTypeAliasDeclaration(node) { + const output = {} + const kind = ts.SyntaxKind[node.kind] + if (kind !== 'TypeAliasDeclaration') throw new Error(`processTypeAliasDeclaration should be of TypeAliasDeclaration`) + for (const mem of node.type.members) { + output[mem.name.text] = processNodeMember(mem) + } + return output +} + +function processRequestTypeAlias(node, typeAliasDeclarationMap = new Map()) { + const kind = ts.SyntaxKind[node.kind] + if (kind !== 'TypeAliasDeclaration') throw new Error(`processTypeAliasDeclaration should be of TypeAliasDeclaration`) + if (!node.type.members.every(mem => ts.SyntaxKind[mem.type.kind] === "TypeLiteral")) { + throw new Error(`for request type alias, expected every type.members to be of type TypeLiteral`) + } + + const output = {} + for (const mem of node.type.members) { + + const requestNode = mem.type.members.find(typeMem => typeMem.name.text === "request") + const responseNode = mem.type.members.find(typeMem => typeMem.name.text === "response") + if (!requestNode || !responseNode) { + let errorText = `for request type alias, every member must have both response and request defined, but ${node.name.text}.${mem.name.text} does not have ` + if (!requestNode) { + errorText += " request " + } + if (!responseNode) { + errorText += " response " + } + errorText += "defined." + throw new Error(errorText) + } + if (!requestNode) { + throwFlag = true + errorText += ` request ` + } + output[mem.name.text] = { + request: processNodeMember(requestNode, typeAliasDeclarationMap), + response: processNodeMember(responseNode, typeAliasDeclarationMap) + } + } + return output +} + +module.exports = { + processTypeAliasDeclaration, + processRequestTypeAlias, +} \ No newline at end of file diff --git a/src/plugin/types.ts b/src/plugin/types.ts new file mode 100644 index 000000000..83ba2e312 --- /dev/null +++ b/src/plugin/types.ts @@ -0,0 +1,4 @@ +export type PluginManifest = { + name: string + url: string +} diff --git a/src/routerModule/routeStateTransform.service.spec.ts b/src/routerModule/routeStateTransform.service.spec.ts index 699e2b118..590619e9e 100644 --- a/src/routerModule/routeStateTransform.service.spec.ts +++ b/src/routerModule/routeStateTransform.service.spec.ts @@ -4,7 +4,7 @@ import { SAPI } from "src/atlasComponents/sapi" import { RouteStateTransformSvc } from "./routeStateTransform.service" import { DefaultUrlSerializer } from "@angular/router" import * as nehubaConfigService from "src/viewerModule/nehuba/config.service" -import { atlasSelection } from "src/state" +import { atlasSelection, userInteraction } from "src/state" import { encodeNumber } from "./cipher" const serializer = new DefaultUrlSerializer() @@ -116,6 +116,10 @@ describe("> routeStateTransform.service.ts", () => { navigation: jasmine.createSpy('navigation'), } + let userInteractionSpy: Record<string, jasmine.Spy> = { + selectedFeature: jasmine.createSpy('selectedFeature') + } + const altasObj = {"@id": 'foo-bar-a'} const templObj = {"@id": 'foo-bar-t'} const parcObj = {"@id": 'foo-bar-p'} @@ -127,6 +131,7 @@ describe("> routeStateTransform.service.ts", () => { spyOnProperty(nehubaConfigService, 'getRegionLabelIndex').and.returnValue(getRegionLabelIndexSpy) spyOnProperty(nehubaConfigService, 'getParcNgId').and.returnValue(getParcNgId) spyOnProperty(atlasSelection, 'selectors').and.returnValue(atlasSelectionSpy) + spyOnProperty(userInteraction, 'selectors').and.returnValue(userInteractionSpy) atlasSelectionSpy.selectedAtlas.and.returnValue(altasObj) atlasSelectionSpy.selectedParcellation.and.returnValue(templObj) @@ -135,13 +140,16 @@ describe("> routeStateTransform.service.ts", () => { atlasSelectionSpy.standaloneVolumes.and.returnValue(standAloneVolumes) atlasSelectionSpy.navigation.and.returnValue(navigation) + userInteractionSpy.selectedFeature.and.returnValue(null) }) afterEach(() => { getRegionLabelIndexSpy.calls.reset() getParcNgId.calls.reset() - for (const key in atlasSelectionSpy) { - atlasSelectionSpy[key].calls.reset() + for (const spyRecord of [atlasSelectionSpy, userInteractionSpy]) { + for (const key in spyRecord) { + spyRecord[key].calls.reset() + } } }) diff --git a/src/state/plugins/effects.spec.ts b/src/state/plugins/effects.spec.ts index fb7436211..98d4d78eb 100644 --- a/src/state/plugins/effects.spec.ts +++ b/src/state/plugins/effects.spec.ts @@ -7,7 +7,6 @@ import { provideMockActions } from "@ngrx/effects/testing"; import { MockStore, provideMockStore } from "@ngrx/store/testing"; import { Injectable } from "@angular/core"; import { getRandomHex } from 'common/util' -import { PluginServices } from "src/plugin"; import { AngularMaterialModule } from "src/sharedModules"; import { hot } from "jasmine-marbles"; import * as actions from "./actions" @@ -75,57 +74,57 @@ class MockPluginService{ } } -describe('pluginUseEffect.ts', () => { +// describe('pluginUseEffect.ts', () => { - let spy: jasmine.Spy - let mockStore: MockStore - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - HttpClientModule, - AngularMaterialModule - ], - providers: [ - Effects, - provideMockActions(() => actions$), - provideMockStore(), - { - provide: HTTP_INTERCEPTORS, - useClass: HTTPInterceptorClass, - multi: true - }, - { - provide: PluginServices, - useClass: MockPluginService - },{ - provide: DialogService, - useValue: { - getUserConfirm() { - return Promise.resolve() - } - } - } - ] - }) - mockStore = TestBed.inject(MockStore) - mockStore.overrideSelector(selectors.initManfests, { [constants.INIT_MANIFEST_SRC]: "http://localhost:12345/manifest.json" }) - const pluginServices = TestBed.inject(PluginServices) - spy = spyOn(pluginServices, 'launchNewWidget') - }) +// let spy: jasmine.Spy +// let mockStore: MockStore +// beforeEach(() => { +// TestBed.configureTestingModule({ +// imports: [ +// HttpClientModule, +// AngularMaterialModule +// ], +// providers: [ +// Effects, +// provideMockActions(() => actions$), +// provideMockStore(), +// { +// provide: HTTP_INTERCEPTORS, +// useClass: HTTPInterceptorClass, +// multi: true +// }, +// { +// provide: PluginServices, +// useClass: MockPluginService +// },{ +// provide: DialogService, +// useValue: { +// getUserConfirm() { +// return Promise.resolve() +// } +// } +// } +// ] +// }) +// mockStore = TestBed.inject(MockStore) +// mockStore.overrideSelector(selectors.initManfests, { [constants.INIT_MANIFEST_SRC]: "http://localhost:12345/manifest.json" }) +// const pluginServices = TestBed.inject(PluginServices) +// spy = spyOn(pluginServices, 'launchNewWidget') +// }) - it('initManifests should fetch manifest.json', fakeAsync(() => { - const effect = TestBed.inject(Effects) - effect.initManLaunch.subscribe() - expect( - effect.initManClear - ).toBeObservable( - hot('a', { - a: actions.clearInitManifests({ - nameSpace: INIT_MANIFEST_SRC - }) - }) - ) - tick(16) - expect(spy).toHaveBeenCalledWith(manifest) - })) -}) +// it('initManifests should fetch manifest.json', fakeAsync(() => { +// const effect = TestBed.inject(Effects) +// effect.initManLaunch.subscribe() +// expect( +// effect.initManClear +// ).toBeObservable( +// hot('a', { +// a: actions.clearInitManifests({ +// nameSpace: INIT_MANIFEST_SRC +// }) +// }) +// ) +// tick(16) +// expect(spy).toHaveBeenCalledWith(manifest) +// })) +// }) diff --git a/src/state/plugins/effects.ts b/src/state/plugins/effects.ts index f2ebeab07..c27a74669 100644 --- a/src/state/plugins/effects.ts +++ b/src/state/plugins/effects.ts @@ -2,9 +2,6 @@ import { Injectable } from "@angular/core"; import { createEffect } from "@ngrx/effects"; import { select, Store } from "@ngrx/store"; import { catchError, filter, map, mapTo, switchMap } from "rxjs/operators"; -import { PluginServices } from "src/plugin"; -import { WidgetServices } from "src/widget"; -import { atlasSelection } from ".." import * as constants from "./const" import * as selectors from "./selectors" import * as actions from "./actions" @@ -15,12 +12,6 @@ import { getHttpHeader } from "src/util/constants" @Injectable() export class Effects{ - onATPUpdateClearWidgets = createEffect(() => this.store.pipe( - atlasSelection.fromRootStore.distinctATP(), - map(() => { - this.widgetSvc.clearAllWidgets() - }) - ), { dispatch: false }) initMan = this.store.pipe( select(selectors.initManfests), @@ -40,9 +31,12 @@ export class Effects{ responseType: 'json' }).toPromise() ) - .then(json => - this.pluginSvc.launchNewWidget(json) - ) + .then(json => { + /** + * TODO fix init plugin launch + * at that time, also restore effects.spec.ts test + */ + }) ), catchError(() => of(null)) ), { dispatch: false }) @@ -57,8 +51,6 @@ export class Effects{ constructor( private store: Store, - private widgetSvc: WidgetServices, - private pluginSvc: PluginServices, private dialogSvc: DialogService, private http: HttpClient, ){ diff --git a/src/state/userInteraction/actions.ts b/src/state/userInteraction/actions.ts index d7b237184..3df6c1b97 100644 --- a/src/state/userInteraction/actions.ts +++ b/src/state/userInteraction/actions.ts @@ -1,7 +1,6 @@ import { createAction, props } from "@ngrx/store" import { nameSpace } from "./const" -import { SapiRegionModel } from "src/atlasComponents/sapi" -import { SapiFeatureModel } from "src/atlasComponents/sapi/type" +import { SapiRegionModel, SapiFeatureModel, OpenMINDSCoordinatePoint } from "src/atlasComponents/sapi" export const mouseOverAnnotations = createAction( `${nameSpace} mouseOverAnnotations`, @@ -19,6 +18,13 @@ export const mouseoverRegions = createAction( }>() ) +export const mouseoverPosition = createAction( + `${nameSpace} mouseoverPosition`, + props<{ + position: OpenMINDSCoordinatePoint + }>() +) + export const showFeature = createAction( `${nameSpace} showFeature`, props<{ diff --git a/src/state/userInteraction/selectors.ts b/src/state/userInteraction/selectors.ts index b9aa84c61..0f43a1a4c 100644 --- a/src/state/userInteraction/selectors.ts +++ b/src/state/userInteraction/selectors.ts @@ -13,3 +13,8 @@ export const selectedFeature = createSelector( selectStore, state => state.selectedFeature ) + +export const mousingOverPosition = createSelector( + selectStore, + state => state.mouseoverPosition +) diff --git a/src/state/userInteraction/store.ts b/src/state/userInteraction/store.ts index 9d7368e8e..ab7570028 100644 --- a/src/state/userInteraction/store.ts +++ b/src/state/userInteraction/store.ts @@ -1,15 +1,17 @@ import { createReducer, on } from "@ngrx/store"; -import { SapiRegionModel, SapiFeatureModel } from "src/atlasComponents/sapi"; +import { SapiRegionModel, SapiFeatureModel, OpenMINDSCoordinatePoint } from "src/atlasComponents/sapi"; import * as actions from "./actions" export type UserInteraction = { mouseoverRegions: SapiRegionModel[] selectedFeature: SapiFeatureModel + mouseoverPosition: OpenMINDSCoordinatePoint } export const defaultState: UserInteraction = { selectedFeature: null, - mouseoverRegions: [] + mouseoverRegions: [], + mouseoverPosition: null } export const reducer = createReducer( @@ -40,5 +42,14 @@ export const reducer = createReducer( selectedFeature: null } } + ), + on( + actions.mouseoverPosition, + (state, { position }) => { + return { + ...state, + mouseoverPosition: position + } + } ) ) diff --git a/src/ui/config/configCmp/config.template.html b/src/ui/config/configCmp/config.template.html index f00d4f15f..eab836a14 100644 --- a/src/ui/config/configCmp/config.template.html +++ b/src/ui/config/configCmp/config.template.html @@ -51,11 +51,6 @@ </div> </mat-tab> - <!-- plugin csp --> - <!-- <mat-tab label="Plugin Permission"> - <plugin-csp-controller></plugin-csp-controller> - </mat-tab> --> - <!-- viewer preference --> <mat-tab *ngIf="experimentalFlag" label="Viewer Preference"> diff --git a/src/util/interfaces.ts b/src/util/interfaces.ts index 73d73fca2..6c057cb1e 100644 --- a/src/util/interfaces.ts +++ b/src/util/interfaces.ts @@ -20,6 +20,11 @@ export type TRegionOfInterest = { ['fullId']: string } export const CANCELLABLE_DIALOG = new InjectionToken('CANCELLABLE_DIALOG') +export type CANCELLABLE_DIALOG_OPTS = Partial<{ + userCancelCallback: () => void + ariaLabel: string +}> + export type TTemplateImage = { name: string '@id': string diff --git a/src/viewerModule/module.ts b/src/viewerModule/module.ts index 179053b1d..6f2c6b5f3 100644 --- a/src/viewerModule/module.ts +++ b/src/viewerModule/module.ts @@ -16,8 +16,6 @@ import { INJ_ANNOT_TARGET } from "src/atlasComponents/userAnnotations/tools/type import { NEHUBA_INSTANCE_INJTKN } from "./nehuba/util"; import { map } from "rxjs/operators"; import { TContextArg } from "./viewer.interface"; -import { API_SERVICE_SET_VIEWER_HANDLE_TOKEN, AtlasViewerAPIServices, setViewerHandleFactory } from "src/atlasViewer/atlasViewer.apiService.service"; -import { ILoadMesh, LOAD_MESH_TOKEN } from "src/messaging/types"; import { KeyFrameModule } from "src/keyframesModule/module"; import { ViewerInternalStateSvc } from "./viewerInternalState.service"; import { SAPIModule } from 'src/atlasComponents/sapi'; @@ -76,21 +74,6 @@ import { FloatingMouseContextualContainerDirective } from "src/util/directives/f }, deps: [ ContextMenuService ] }, - { - provide: API_SERVICE_SET_VIEWER_HANDLE_TOKEN, - useFactory: setViewerHandleFactory, - deps: [ AtlasViewerAPIServices ] - }, - { - provide: LOAD_MESH_TOKEN, - useFactory: (apiService: AtlasViewerAPIServices) => { - return (loadMeshParam: ILoadMesh) => apiService.loadMesh$.next(loadMeshParam) - }, - deps: [ - AtlasViewerAPIServices - ] - }, - AtlasViewerAPIServices, ViewerInternalStateSvc, ], exports: [ diff --git a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts index b5710f3c8..c719c98ce 100644 --- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts +++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts @@ -6,7 +6,6 @@ import { distinctUntilChanged, startWith } from "rxjs/operators"; import { ARIA_LABELS } from 'common/constants' import { EnumViewerEvt, IViewer, TViewerEvent } from "../../viewer.interface"; import { NehubaViewerContainerDirective, TMouseoverEvent } from "../nehubaViewerInterface/nehubaViewerInterface.directive"; -import { API_SERVICE_SET_VIEWER_HANDLE_TOKEN, TSetViewerHandle } from "src/atlasViewer/atlasViewer.apiService.service"; import { NehubaMeshService } from "../mesh.service"; import { NehubaLayerControlService, SET_COLORMAP_OBS, SET_LAYER_VISIBILITY } from "../layerCtrl.service"; import { getExportNehuba, getUuid } from "src/util/fn"; @@ -187,7 +186,6 @@ export class NehubaGlueCmp implements IViewer<'nehuba'>, OnDestroy, AfterViewIni private worker: AtlasWorkerService, private layerCtrlService: NehubaLayerControlService, @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) clickInterceptor: ClickInterceptor, - @Optional() @Inject(API_SERVICE_SET_VIEWER_HANDLE_TOKEN) setViewerHandle: TSetViewerHandle, ){ /** * This **massively** improve the performance of the viewer @@ -236,10 +234,6 @@ export class NehubaGlueCmp implements IViewer<'nehuba'>, OnDestroy, AfterViewIni this.onhoverSegments = arr }) this.onDestroyCb.push(() => onhovSegSub.unsubscribe()) - - if (setViewerHandle) { - console.warn(`NYI viewer handle is deprecated`) - } } diff --git a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts index 89e2df55c..df0dcd3f9 100644 --- a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts +++ b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts @@ -7,7 +7,6 @@ import { select, Store } from "@ngrx/store"; import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR } from "src/util"; import { MatSnackBar } from "@angular/material/snack-bar"; import { CONST } from 'common/constants' -import { API_SERVICE_SET_VIEWER_HANDLE_TOKEN, TSetViewerHandle } from "src/atlasViewer/atlasViewer.apiService.service"; import { getUuid } from "src/util/fn"; import { AUTO_ROTATE, TInteralStatePayload, ViewerInternalStateSvc } from "src/viewerModule/viewerInternalState.service"; import { atlasAppearance, atlasSelection } from "src/state"; @@ -201,7 +200,6 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit private snackbar: MatSnackBar, @Optional() intViewerStateSvc: ViewerInternalStateSvc, @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) clickInterceptor: ClickInterceptor, - @Optional() @Inject(API_SERVICE_SET_VIEWER_HANDLE_TOKEN) setViewerHandle: TSetViewerHandle, ){ if (intViewerStateSvc) { const { @@ -231,74 +229,6 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit this.onDestroyCb.push(() => done()) } - // set viewer handle - // the API won't be 100% compatible with ngviewer - if (setViewerHandle) { - const nyi = () => { - throw new Error(`Not yet implemented`) - } - setViewerHandle({ - add3DLandmarks: nyi, - loadLayer: nyi, - applyLayersColourMap: function(map: Map<string, Map<number, { red: number, green: number, blue: number }>>){ - throw new Error(`NYI`) - // if (this.loanedColorMap.has(map)) { - // this.externalHemisphLblColorMap = null - // } else { - - // const applyCm = new Map() - // for (const [hem, m] of map.entries()) { - // const nMap = new Map() - // applyCm.set(hem, nMap) - // for (const [lbl, vals] of m.entries()) { - // const { red, green, blue } = vals - // nMap.set(lbl, [red/255, green/255, blue/255]) - // } - // } - // this.externalHemisphLblColorMap = applyCm - // } - // this.applyColorMap() - }, - getLayersSegmentColourMap: () => { - throw new Error(`NYI`) - // const map = this.getColormapCopy() - // const outmap = new Map<string, Map<number, { red: number, green: number, blue: number }>>() - // for (const [ hem, m ] of map.entries()) { - // const nMap = new Map<number, {red: number, green: number, blue: number}>() - // outmap.set(hem, nMap) - // for (const [ lbl, vals ] of m.entries()) { - // nMap.set(lbl, { - // red: vals[0] * 255, - // green: vals[1] * 255, - // blue: vals[2] * 255, - // }) - // } - // } - // this.loanedColorMap.add(outmap) - // return outmap - }, - getNgHash: nyi, - hideAllSegments: nyi, - hideSegment: nyi, - mouseEvent: null, - mouseOverNehuba: null, - mouseOverNehubaUI: null, - moveToNavigationLoc: null, - moveToNavigationOri: null, - remove3DLandmarks: null, - removeLayer: null, - setLayerVisibility: null, - setNavigationLoc: null, - setNavigationOri: null, - showAllSegments: nyi, - showSegment: nyi, - }) - } - - this.onDestroyCb.push( - () => setViewerHandle(null) - ) - /** * intercept click and act */ diff --git a/src/viewerModule/viewerCmp/viewerCmp.component.ts b/src/viewerModule/viewerCmp/viewerCmp.component.ts index 31b28ddfb..d28f45292 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.component.ts +++ b/src/viewerModule/viewerCmp/viewerCmp.component.ts @@ -10,7 +10,8 @@ import { ContextMenuService, TContextMenuReg } from "src/contextMenuModule"; import { DialogService } from "src/services/dialogService.service"; import { SAPI, SapiRegionModel } from "src/atlasComponents/sapi"; import { atlasSelection, userInteraction, } from "src/state"; -import { SapiSpatialFeatureModel, SapiFeatureModel, SapiParcellationModel } from "src/atlasComponents/sapi/type"; +import { SapiSpatialFeatureModel, SapiFeatureModel, SapiParcellationModel, SapiSpaceModel } from "src/atlasComponents/sapi/type"; +import { getUuid } from "src/util/fn"; @Component({ selector: 'iav-cmp-viewer-container', @@ -184,7 +185,7 @@ export class ViewerCmp implements OnDestroy { private viewerStatusRegionCtxMenu: TemplateRef<any> public context: TContextArg<TSupportedViewers> - private templateSelected: any + private templateSelected: SapiSpaceModel constructor( private store$: Store<any>, @@ -324,7 +325,7 @@ export class ViewerCmp implements OnDestroy { case EnumViewerEvt.VIEWER_CTX: this.ctxMenuSvc.context$.next(event.data) if (event.data.viewerType === "nehuba") { - const { nehuba } = (event.data as TContextArg<"nehuba">).payload + const { nehuba, nav } = (event.data as TContextArg<"nehuba">).payload if (nehuba) { const mousingOverRegions = (nehuba || []).reduce((acc, { regions }) => acc.concat(...regions), []) this.store$.dispatch( @@ -333,6 +334,24 @@ export class ViewerCmp implements OnDestroy { }) ) } + if (nav) { + this.store$.dispatch( + userInteraction.actions.mouseoverPosition({ + position: { + "@id": getUuid(), + "@type": "https://openminds.ebrains.eu/sands/CoordinatePoint", + coordinates: nav.position.map(p => { + return { + value: p, + } + }), + coordinateSpace: { + '@id': this.templateSelected["@id"] + } + } + }) + ) + } } break default: diff --git a/src/viewerModule/viewerCmp/viewerCmp.template.html b/src/viewerModule/viewerCmp/viewerCmp.template.html index 05481d0b4..37afe5b4c 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.template.html +++ b/src/viewerModule/viewerCmp/viewerCmp.template.html @@ -665,7 +665,7 @@ </ng-container> </div> - <div class="flex-shrink-1 flex-grow-1 d-flex flex-column" + <div class="flex-shrink-1 flex-grow-1 d-flex flex-column sxplr-h-100" [ngClass]="{'region-populated': (selectedRegions$ | async).length > 0 }"> <!-- region detail --> <ng-container *ngIf="selectedRegions$ | async as selectedRegions; else selectRegionErrorTmpl"> diff --git a/src/widget/constants.ts b/src/widget/constants.ts index ede7e8805..412bccd40 100644 --- a/src/widget/constants.ts +++ b/src/widget/constants.ts @@ -1,3 +1,4 @@ +import { InjectionToken } from "@angular/core"; import { MatDialogConfig, MatDialogRef } from "@angular/material/dialog"; export enum EnumActionToWidget{ @@ -19,3 +20,4 @@ interface TypeActionWidgetReturnVal<T>{ export type TypeActionToWidget<T> = (type: EnumActionToWidget, obj: T, option: IActionWidgetOption) => TypeActionWidgetReturnVal<T> +export const WIDGET_PORTAL_TOKEN = new InjectionToken<Record<string, unknown>>("WIDGET_PORTAL_TOKEN") diff --git a/src/widget/index.ts b/src/widget/index.ts index c322c25f3..1cabfcd56 100644 --- a/src/widget/index.ts +++ b/src/widget/index.ts @@ -1,4 +1,4 @@ export { WidgetModule } from './widget.module' -export { WidgetUnit } from './widgetUnit/widgetUnit.component' -export { IWidgetOptionsInterface, WidgetServices } from './widgetService.service' +export { WidgetPortal } from "./widgetPortal/widgetPortal.component" +export { WidgetService } from "./service" export { EnumActionToWidget, TypeActionToWidget, IActionWidgetOption } from './constants' diff --git a/src/widget/service.ts b/src/widget/service.ts new file mode 100644 index 000000000..75be50886 --- /dev/null +++ b/src/widget/service.ts @@ -0,0 +1,48 @@ +import { ComponentPortal } from "@angular/cdk/portal"; +import { ComponentFactory, ComponentFactoryResolver, ComponentRef, Injectable, Injector, ViewContainerRef } from "@angular/core"; +import { WidgetPortal } from "./widgetPortal/widgetPortal.component"; + +@Injectable({ + providedIn: 'root' +}) + +export class WidgetService { + + public vcr: ViewContainerRef + + private viewRefMap = new Map<WidgetPortal<unknown>, ComponentRef<WidgetPortal<unknown>>>() + private cf: ComponentFactory<WidgetPortal<unknown>> + + constructor(cfr: ComponentFactoryResolver){ + this.cf = cfr.resolveComponentFactory(WidgetPortal) + } + + public addNewWidget<T>(Component: new (...arg: any) => T, injector: Injector): WidgetPortal<T> { + const widgetPortal = this.vcr.createComponent(this.cf, 0, injector) as ComponentRef<WidgetPortal<T>> + const cmpPortal = new ComponentPortal<T>(Component, this.vcr, injector) + + this.viewRefMap.set(widgetPortal.instance, widgetPortal) + + widgetPortal.instance.portal = cmpPortal + return widgetPortal.instance + } + + public rmWidget(wdg: WidgetPortal<unknown>) { + + /** + * if wdg no longer exist in viewRefMap, it should already been deleted. + */ + if (!this.viewRefMap.has(wdg)) { + return + } + const hostView = this.viewRefMap.get(wdg).hostView + + this.viewRefMap.delete(wdg) + + const idx = this.vcr.indexOf(hostView) + if (idx < 0) { + console.warn(`idx less than 0, cannot remove`) + } + this.vcr.remove(idx) + } +} diff --git a/src/widget/widget.module.ts b/src/widget/widget.module.ts index dd6b7960a..138ed6648 100644 --- a/src/widget/widget.module.ts +++ b/src/widget/widget.module.ts @@ -1,27 +1,29 @@ import { NgModule } from "@angular/core"; -import { WidgetUnit } from "./widgetUnit/widgetUnit.component"; -import { WidgetServices } from "./widgetService.service"; -import { AngularMaterialModule } from "src/sharedModules"; import { CommonModule } from "@angular/common"; import { ComponentsModule } from "src/components"; +import { WidgetCanvas } from "./widgetCanvas.directive"; +import { WidgetPortal } from "./widgetPortal/widgetPortal.component"; +import { MatCardModule } from "@angular/material/card"; +import { DragDropModule } from "@angular/cdk/drag-drop"; +import { MatButtonModule } from "@angular/material/button"; +import { PortalModule } from "@angular/cdk/portal"; @NgModule({ imports:[ - AngularMaterialModule, + MatCardModule, + DragDropModule, + MatButtonModule, + PortalModule, CommonModule, ComponentsModule, ], declarations: [ - WidgetUnit - ], - entryComponents: [ - WidgetUnit - ], - providers: [ - WidgetServices, + WidgetCanvas, + WidgetPortal, ], + providers: [], exports: [ - WidgetUnit + WidgetCanvas, ] }) diff --git a/src/widget/widgetCanvas.directive.ts b/src/widget/widgetCanvas.directive.ts new file mode 100644 index 000000000..206548386 --- /dev/null +++ b/src/widget/widgetCanvas.directive.ts @@ -0,0 +1,15 @@ +import { Directive, ViewContainerRef } from "@angular/core"; +import { WidgetService } from "./service"; + +@Directive({ + selector: `[widget-canvas]` +}) + +export class WidgetCanvas { + constructor( + wSvc: WidgetService, + vcr: ViewContainerRef, + ){ + wSvc.vcr = vcr + } +} diff --git a/src/widget/widgetPortal/widgetPortal.component.ts b/src/widget/widgetPortal/widgetPortal.component.ts new file mode 100644 index 000000000..a1351e15e --- /dev/null +++ b/src/widget/widgetPortal/widgetPortal.component.ts @@ -0,0 +1,41 @@ +import { ComponentPortal } from "@angular/cdk/portal"; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component } from "@angular/core"; +import { WidgetService } from "../service"; + +@Component({ + selector: 'sxplr-widget-portal', + templateUrl: './widgetPortal.template.html', + styleUrls: [ + './widgetPortal.style.css' + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) + +export class WidgetPortal<T>{ + + portal: ComponentPortal<T> + + private _name: string + get name() { + return this._name + } + set name(val) { + this._name = val + this.cdr.markForCheck() + } + + defaultPosition = { + x: 200, + y: 200, + } + + constructor( + private wSvc: WidgetService, + private cdr: ChangeDetectorRef, + ){ + + } + exit(){ + this.wSvc.rmWidget(this) + } +} diff --git a/src/widget/widgetPortal/widgetPortal.style.css b/src/widget/widgetPortal/widgetPortal.style.css new file mode 100644 index 000000000..12e9c8096 --- /dev/null +++ b/src/widget/widgetPortal/widgetPortal.style.css @@ -0,0 +1,56 @@ +:host +{ + pointer-events: none; + display: block; + max-width: 24rem; +} + +mat-card +{ + pointer-events: all; + max-width: 36vw; + height: 36rem; + max-height: 90vh; +} + +mat-card-content +{ + height: 100%; + width: 100%; + display: flex; + flex-direction: column; +} + +.widget-portal-header +{ + display: flex; + justify-content: space-between; + align-items: center; +} + +.widget-portal-content +{ + flex-grow: 1; +} + +.hover-grab +{ + opacity: 0.5; + transition: opacity 200ms ease-in-out; + cursor: move; +} + +.hover-grab:hover +{ + opacity: 1.0; +} + +.widget-grab-handle +{ + margin-right:1rem; +} + +.widget-name +{ + flex-grow: 1; +} diff --git a/src/widget/widgetPortal/widgetPortal.template.html b/src/widget/widgetPortal/widgetPortal.template.html new file mode 100644 index 000000000..7aff890d9 --- /dev/null +++ b/src/widget/widgetPortal/widgetPortal.template.html @@ -0,0 +1,22 @@ +<mat-card cdkDrag [cdkDragFreeDragPosition]="defaultPosition"> + <mat-card-content> + <div class="widget-portal-header" cdkDragHandle> + <span class="hover-grab widget-grab-handle"> + <i class="fas fa-grip-vertical"></i> + </span> + + <span *ngIf="name" class="widget-name"> + {{ name }} + </span> + + <button mat-icon-button (click)="exit()"> + <i class="fas fa-times"></i> + </button> + </div> + + <div class="widget-portal-content"> + <ng-template [cdkPortalOutlet]="portal"> + </ng-template> + </div> + </mat-card-content> +</mat-card> diff --git a/src/widget/widgetService.service.ts b/src/widget/widgetService.service.ts deleted file mode 100644 index 89d198961..000000000 --- a/src/widget/widgetService.service.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { ComponentFactory, ComponentFactoryResolver, ComponentRef, Injectable, Injector, OnDestroy, ViewContainerRef } from "@angular/core"; -import { BehaviorSubject, Subscription } from "rxjs"; -import { LoggingService } from "src/logging"; -import { WidgetUnit } from "./widgetUnit/widgetUnit.component"; - -@Injectable({ - providedIn : 'root', -}) - -export class WidgetServices implements OnDestroy { - - public floatingContainer: ViewContainerRef - public dockedContainer: ViewContainerRef - public factoryContainer: ViewContainerRef - - private widgetUnitFactory: ComponentFactory<WidgetUnit> - private widgetComponentRefs: Set<ComponentRef<WidgetUnit>> = new Set() - - private clickedListener: Subscription[] = [] - - public minimisedWindow$: BehaviorSubject<Set<WidgetUnit>> - private minimisedWindow: Set<WidgetUnit> = new Set() - - constructor( - private cfr: ComponentFactoryResolver, - private injector: Injector, - private log: LoggingService, - ) { - this.widgetUnitFactory = this.cfr.resolveComponentFactory(WidgetUnit) - this.minimisedWindow$ = new BehaviorSubject(this.minimisedWindow) - } - - private subscriptions: Subscription[] = [] - - - public ngOnDestroy() { - while (this.subscriptions.length > 0) { - this.subscriptions.pop().unsubscribe() - } - } - - public clearAllWidgets() { - [...this.widgetComponentRefs].forEach((cr: ComponentRef<WidgetUnit>) => { - if (!cr.instance.persistency) { cr.destroy() } - }) - - this.clickedListener.forEach(s => s.unsubscribe()) - } - - public rename(wu: WidgetUnit, {title, titleHTML}: {title: string, titleHTML: string}) { - /** - * WARNING: always sanitize before pass to rename fn! - */ - wu.title = title - wu.titleHTML = titleHTML - } - - public minimise(wu: WidgetUnit) { - this.minimisedWindow.add(wu) - this.minimisedWindow$.next(new Set(this.minimisedWindow)) - } - - public isMinimised(wu: WidgetUnit) { - return this.minimisedWindow.has(wu) - } - - public unminimise(wu: WidgetUnit) { - this.minimisedWindow.delete(wu) - this.minimisedWindow$.next(new Set(this.minimisedWindow)) - } - - public addNewWidget(guestComponentRef: ComponentRef<any>, options?: Partial<IWidgetOptionsInterface>): ComponentRef<WidgetUnit> { - const component = this.widgetUnitFactory.create(this.injector) - const _option = getOption(options) - - // TODO bring back docked state? - _option.state = 'floating' - - _option.state === 'floating' - ? this.floatingContainer.insert(component.hostView) - : _option.state === 'docked' - ? this.dockedContainer.insert(component.hostView) - : this.floatingContainer.insert(component.hostView) - - if (component.constructor === Error) { - throw component - } else { - const _component = (component as ComponentRef<WidgetUnit>) - - // guestComponentRef - // insert view - _component.instance.container.insert( guestComponentRef.hostView ) - // on host destroy, destroy guest - _component.onDestroy(() => guestComponentRef.destroy()) - - /* programmatic DI */ - _component.instance.widgetServices = this - - /* common properties */ - _component.instance.state = _option.state - _component.instance.exitable = _option.exitable - _component.instance.title = _option.title - _component.instance.persistency = _option.persistency - _component.instance.titleHTML = _option.titleHTML - - /* internal properties, used for changing state */ - _component.instance.guestComponentRef = guestComponentRef - - if (_option.state === 'floating') { - let position = [400, 100] as [number, number] - while ([...this.widgetComponentRefs].some(widget => - widget.instance.state === 'floating' && - widget.instance.position.every((v, idx) => v === position[idx]))) { - position = position.map(v => v + 10) as [number, number] - } - _component.instance.position = position - } - - /* set width and height. or else floating components will obstruct viewers */ - _component.instance.setWidthHeight() - - this.widgetComponentRefs.add( _component ) - _component.onDestroy(() => this.minimisedWindow.delete(_component.instance)) - - this.clickedListener.push( - _component.instance.clickedEmitter.subscribe((widgetUnit: WidgetUnit) => { - /** - * TODO this operation - */ - if (widgetUnit.state !== 'floating') { - return - } - const foundWidgetCompRef = [...this.widgetComponentRefs].find(wr => wr.instance === widgetUnit) - if (!foundWidgetCompRef) { - return - } - const idx = this.floatingContainer.indexOf(foundWidgetCompRef.hostView) - if (idx === this.floatingContainer.length - 1 ) { - return - } - this.floatingContainer.detach(idx) - this.floatingContainer.insert(foundWidgetCompRef.hostView) - }), - ) - - return _component - } - } - - public changeState(widgetUnit: WidgetUnit, options: IWidgetOptionsInterface) { - const widgetRef = [...this.widgetComponentRefs].find(cr => cr.instance === widgetUnit) - if (widgetRef) { - this.widgetComponentRefs.delete(widgetRef) - widgetRef.instance.container.detach( 0 ) - const guestComopnent = widgetRef.instance.guestComponentRef - this.addNewWidget(guestComopnent, options) - - widgetRef.destroy() - } else { - this.log.warn('widgetref not found') - } - } - - public exitWidget(widgetUnit: WidgetUnit) { - const widgetRef = [...this.widgetComponentRefs].find(cr => cr.instance === widgetUnit) - if (widgetRef) { - widgetRef.destroy() - this.widgetComponentRefs.delete(widgetRef) - } else { - this.log.warn('widgetref not found') - } - } - - public dockAllWidgets() { - /* nb cannot directly iterate the set, as the set will be updated and create and infinite loop */ - [...this.widgetComponentRefs].forEach(cr => cr.instance.dock()) - } - - public floatAllWidgets() { - [...this.widgetComponentRefs].forEach(cr => cr.instance.undock()) - } -} - -function safeGetSingle(obj: any, arg: string) { - return typeof obj === 'object' && obj !== null && typeof arg === 'string' - ? obj[arg] - : null -} - -function safeGet(obj: any, ...args: string[]) { - let _obj = Object.assign({}, obj) - while (args.length > 0) { - const arg = args.shift() - _obj = safeGetSingle(_obj, arg) - } - return _obj -} - -function getOption(option?: Partial<IWidgetOptionsInterface>): IWidgetOptionsInterface { - return{ - exitable : safeGet(option, 'exitable') !== null - ? safeGet(option, 'exitable') - : true, - state : safeGet(option, 'state') || 'floating', - title : safeGet(option, 'title') || 'Untitled', - persistency : safeGet(option, 'persistency') || false, - titleHTML: safeGet(option, 'titleHTML') || null, - } -} - -export interface IWidgetOptionsInterface { - title?: string - state?: 'docked' | 'floating' - exitable?: boolean - persistency?: boolean - titleHTML?: string -} diff --git a/src/widget/widgetUnit/widgetUnit.component.ts b/src/widget/widgetUnit/widgetUnit.component.ts deleted file mode 100644 index e77afb49a..000000000 --- a/src/widget/widgetUnit/widgetUnit.component.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { Component, ComponentRef, EventEmitter, HostBinding, HostListener, Input, OnDestroy, OnInit, Output, ViewChild, ViewContainerRef } from "@angular/core"; - -import { Observable, Subscription } from "rxjs"; -import { map } from "rxjs/operators"; -import { WidgetServices } from "../widgetService.service"; - -@Component({ - templateUrl : './widgetUnit.template.html', - styleUrls : [ - `./widgetUnit.style.css`, - ], -}) - -export class WidgetUnit implements OnInit, OnDestroy { - @ViewChild('container', {read: ViewContainerRef, static: true}) public container: ViewContainerRef - - @HostBinding('attr.state') - public state: 'docked' | 'floating' = 'docked' - - @HostBinding('style.width') - public width: string = this.state === 'docked' ? null : '0px' - - @HostBinding('style.height') - public height: string = this.state === 'docked' ? null : '0px' - - @HostBinding('style.display') - public isMinimised: string - - public isMinimised$: Observable<boolean> - - public hoverableConfig = { - translateY: -1, - } - - /** - * Timed alternates of blinkOn property should result in attention grabbing blink behaviour - */ - private _blinkOn: boolean = false - get blinkOn() { - return this._blinkOn - } - - set blinkOn(val: boolean) { - this._blinkOn = !!val - } - - get showProgress() { - return this.progressIndicator !== null - } - - /** - * Some plugins may like to show progress indicator for long running processes - * If null, no progress is running - * This value should be between 0 and 1 - */ - private _progressIndicator: number = null - get progressIndicator() { - return this._progressIndicator - } - - set progressIndicator(val: number) { - if (isNaN(val)) { - this._progressIndicator = null - return - } - if (val < 0) { - this._progressIndicator = 0 - return - } - if (val > 1) { - this._progressIndicator = 1 - return - } - this._progressIndicator = val - } - - public canBeDocked: boolean = false - @HostListener('mousedown') - public clicked() { - this.clickedEmitter.emit(this) - this.blinkOn = false - } - - @Input() public title: string = 'Untitled' - - @Output() - public clickedEmitter: EventEmitter<WidgetUnit> = new EventEmitter() - - @Input() - public exitable: boolean = true - - @Input() - public titleHTML: string = null - - public guestComponentRef: ComponentRef<any> - public widgetServices: WidgetServices - public cf: ComponentRef<WidgetUnit> - private subscriptions: Subscription[] = [] - - public id: string - constructor() { - this.id = Date.now().toString() - } - - public ngOnInit() { - this.canBeDocked = typeof this.widgetServices.dockedContainer !== 'undefined' - - this.isMinimised$ = this.widgetServices.minimisedWindow$.pipe( - map(set => set.has(this)), - ) - this.subscriptions.push( - this.isMinimised$.subscribe(flag => this.isMinimised = flag ? 'none' : null), - ) - } - - public ngOnDestroy() { - while (this.subscriptions.length > 0) { - this.subscriptions.pop().unsubscribe() - } - } - - /** - * @param {boolean} - * @description when new viewer is init, if this viewer will persist - * @default false - * @TODO does it make sense to tie widget persistency with WidgetUnit class? - */ - public persistency: boolean = false - - public undock(event?: Event) { - if (event) { - event.stopPropagation() - event.preventDefault() - } - - this.widgetServices.changeState(this, { - title : this.title, - state: 'floating', - exitable: this.exitable, - persistency: this.persistency, - }) - } - - public dock(event?: Event) { - if (event) { - event.stopPropagation() - event.preventDefault() - } - - this.widgetServices.changeState(this, { - title : this.title, - state: 'docked', - exitable: this.exitable, - persistency: this.persistency, - }) - } - - public exit(event?: Event) { - if (event) { - event.stopPropagation() - event.preventDefault() - } - - this.widgetServices.exitWidget(this) - } - - public setWidthHeight() { - this.width = this.state === 'docked' ? null : '0px' - this.height = this.state === 'docked' ? null : '0px' - } - - /* floating widget specific functionalities */ - - @HostBinding('style.transform') - get styleTransform() { - return this.state === 'floating' ? `translate(${this.position.map(v => v + 'px').join(',')})` : null - } - - public position: [number, number] = [400, 100] -} diff --git a/src/widget/widgetUnit/widgetUnit.style.css b/src/widget/widgetUnit/widgetUnit.style.css deleted file mode 100644 index 9002aa7d4..000000000 --- a/src/widget/widgetUnit/widgetUnit.style.css +++ /dev/null @@ -1,91 +0,0 @@ -:host -{ - pointer-events: all; - display:block; -} - -div[widgetUnitHeading] -{ - font-size:110%; - padding : 0.5em 0.7em; - display:flex; - white-space: nowrap; -} - - div[widgetUnitHeading] > div[title] - { - flex : 1 1 0px; - overflow:hidden; - text-overflow: ellipsis; - } - - div[widgetUnitHeading] > div[icons] - { - flex : 0 0 0px; - display:flex; - } - - div[widgetUnitHeading] > div[icons] > i - { - margin-left:0.5em; - } - -.widget-body -{ - min-width:280px; -} - -:host-context([state='floating']) div[widgetUnitHeading]:hover -{ - cursor : move; -} - -@keyframes blinkDark -{ - 0% { - border-color: rgba(128, 128, 200, 0.0); - } - - 100% { - border-color: rgba(128, 128, 200, 1.0); - } -} - -@keyframes blink -{ - 0% { - border-color: rgba(128, 128, 255, 0.0); - } - - 100% { - border-color: rgba(128, 128, 255, 1.0); - } -} - -:host-context([darktheme="true"]) .blinkOn -{ - animation: 0.5s blinkDark ease-in-out 9 alternate; - border: 1px solid rgba(128, 128, 200, 1.0) !important; -} - -:host-context([darktheme="false"]) .blinkOn -{ - animation: 0.5s blink ease-in-out 9 alternate; - border: 1px solid rgba(128, 128, 255, 1.0) !important; -} - -[heading] -{ - position:relative; -} - -[heading] > [progressBar] -{ - position: absolute; - width: 100%; - height: 100%; - left: 0; - top: 0; - opacity: 0.4; - pointer-events: none; -} diff --git a/src/widget/widgetUnit/widgetUnit.template.html b/src/widget/widgetUnit/widgetUnit.template.html deleted file mode 100644 index ac3114169..000000000 --- a/src/widget/widgetUnit/widgetUnit.template.html +++ /dev/null @@ -1,42 +0,0 @@ -<mat-card cdkDrag - [cdkDragDisabled]="state === 'docked'" - [ngClass]="{'blinkOn': blinkOn, 'bodyCollapsable': state === 'docked'}" - class="widget-body"> - - <!-- body --> - <mat-card-content> - <!-- top bar, drag handle etc --> - <div class="d-flex align-items-center"> - <!-- drag handle --> - <span class="hover-grab sxplr-p-2" - cdkDragHandle> - <i class="fas fa-grip-vertical"></i> - </span> - - <div class="flex-grow-1"></div> - - <!-- close btn --> - <button mat-icon-button - (click)="exit($event)"> - <i class="fas fa-times"></i> - </button> - </div> - - <h4 class="mat-h4"> - <ng-template [ngTemplateOutlet]="titleTmpl"> - </ng-template> - </h4> - <ng-template #container> - </ng-template> - </mat-card-content> -</mat-card> - - -<!-- title tmpl --> -<ng-template #titleTmpl> - <div *ngIf="!titleHTML"> - {{ title }} - </div> - <div [innerHTML]="titleHTML" *ngIf="titleHTML"> - </div> -</ng-template> -- GitLab