import { uiActionSetPreviewingDatasetFiles, IDatasetPreviewData, uiStateShowBottomSheet, uiStatePreviewingDatasetFilesSelector } from "./services/state/uiState.store.helper" import { OnDestroy, Injectable, Optional, Inject, InjectionToken } from "@angular/core" import { PreviewComponentWrapper, DatasetPreview, determinePreviewFileType, EnumPreviewFileTypes, IKgDataEntry, getKgSchemaIdFromFullId, GET_KGDS_PREVIEW_INFO_FROM_ID_FILENAME } from "./atlasComponents/databrowserModule/pure" import { Subscription, Observable, forkJoin, of, merge, combineLatest } from "rxjs" import { select, Store, ActionReducer, createAction, props, createSelector, Action } from "@ngrx/store" import { startWith, map, shareReplay, pairwise, debounceTime, distinctUntilChanged, tap, switchMap, withLatestFrom, mapTo, switchMapTo, filter, skip, catchError, bufferTime } from "rxjs/operators" import { TypeActionToWidget, EnumActionToWidget, ACTION_TO_WIDGET_TOKEN } from "./widget" import { getIdObj } from 'common/util' import { MatDialogRef } from "@angular/material/dialog" import { HttpClient } from "@angular/common/http" import { DS_PREVIEW_URL, getShader, PMAP_DEFAULT_CONFIG } from 'src/util/constants' import { ngViewerActionAddNgLayer, ngViewerActionRemoveNgLayer, INgLayerInterface } from "./services/state/ngViewerState.store.helper" import { ARIA_LABELS } from 'common/constants' import { NgLayersService } from "src/ui/layerbrowser/ngLayerService.service" import { EnumColorMapName } from "./util/colorMaps" import { Effect } from "@ngrx/effects" import { viewerStateSelectedRegionsSelector, viewerStateSelectedTemplateSelector, viewerStateSelectedParcellationSelector } from "./services/state/viewerState/selectors" import { ngViewerSelectorClearView } from "./services/state/ngViewerState/selectors" import { ngViewerActionClearView } from './services/state/ngViewerState/actions' import { generalActionError } from "./services/stateStore.helper" import { TClickInterceptorConfig } from "./util/injectionTokens" const PREVIEW_FILE_TYPES_NO_UI = [ EnumPreviewFileTypes.NIFTI, EnumPreviewFileTypes.VOLUMES ] const DATASET_PREVIEW_ANNOTATION = `DATASET_PREVIEW_ANNOTATION` const prvFilterNull = ({ prvToDismiss, prvToShow }) => ({ prvToDismiss: prvToDismiss.filter(v => !!v), prvToShow: prvToShow.filter(v => !!v), }) export const glueActionToggleDatasetPreview = createAction( '[glue] toggleDatasetPreview', props<{ datasetPreviewFile: IDatasetPreviewData }>() ) export const glueActionAddDatasetPreview = createAction( '[glue] addDatasetPreview', props<{ datasetPreviewFile: IDatasetPreviewData }>() ) export const glueActionRemoveDatasetPreview = createAction( '[glue] removeDatasetPreview', props<{ datasetPreviewFile: IDatasetPreviewData }>() ) export const glueSelectorGetUiStatePreviewingFiles = createSelector( (state: any) => state.uiState, uiState => uiState.previewingDatasetFiles ) export interface IDatasetPreviewGlue{ datasetPreviewDisplayed(file: DatasetPreview, dataset: IKgDataEntry): Observable<boolean> displayDatasetPreview(previewFile: DatasetPreview, dataset: IKgDataEntry): void } @Injectable({ providedIn: 'root' }) export class GlueEffects { public regionTemplateParcChange$ = merge( this.store$.pipe( select(viewerStateSelectedRegionsSelector), map(rs => (rs || []).map(r => r['name']).sort().join(',')), distinctUntilChanged(), skip(1), ), this.store$.pipe( select(viewerStateSelectedTemplateSelector), map(tmpl => tmpl ? tmpl['@id'] || tmpl['name'] : null), distinctUntilChanged(), skip(1) ), this.store$.pipe( select(viewerStateSelectedParcellationSelector), map(parc => parc ? parc['@id'] || parc['name'] : null), distinctUntilChanged(), skip(1) ) ).pipe( mapTo(true) ) @Effect() resetDatasetPreview$: Observable<any> = this.store$.pipe( select(uiStatePreviewingDatasetFilesSelector), distinctUntilChanged(), filter(previews => previews?.length > 0), switchMapTo(this.regionTemplateParcChange$) ).pipe( mapTo(uiActionSetPreviewingDatasetFiles({ previewingDatasetFiles: [] })) ) unsuitablePreviews$: Observable<any> = merge( /** * filter out the dataset previews, whose details cannot be fetchd from getdatasetPreviewFromId method */ this.store$.pipe( select(uiStatePreviewingDatasetFilesSelector), switchMap(previews => forkJoin( previews.map(prev => this.getDatasetPreviewFromId(prev).pipe( // filter out the null's filter(val => !val), mapTo(prev) )) ).pipe( filter(previewFiles => previewFiles.length > 0) ) ) ), /** * filter out the dataset previews, whose details can be fetched from getDatasetPreviewFromId method */ combineLatest([ this.store$.pipe( select(viewerStateSelectedTemplateSelector) ), this.store$.pipe( select(uiStatePreviewingDatasetFilesSelector), switchMap(previews => forkJoin( previews.map(prev => this.getDatasetPreviewFromId(prev).pipe( filter(val => !!val) )) ).pipe( // filter out the null's filter(previewFiles => previewFiles.length > 0) ) ), ) ]).pipe( map(([ templateSelected, previewFiles ]) => previewFiles.filter(({ referenceSpaces }) => // if referenceSpaces of the dataset preview is undefined, assume it is suitable for all reference spaces (!referenceSpaces) ? false : !referenceSpaces.some(({ fullId }) => fullId === '*' || fullId === templateSelected.fullId) ) ), ) ).pipe( filter(arr => arr.length > 0), shareReplay(1), ) @Effect() uiRemoveUnsuitablePreviews$: Observable<any> = this.unsuitablePreviews$.pipe( map(previews => generalActionError({ message: `Dataset previews ${previews.map(v => v.name)} cannot be displayed.` })) ) @Effect() filterDatasetPreviewByTemplateSelected$: Observable<any> = this.unsuitablePreviews$.pipe( withLatestFrom( this.store$.pipe( select(uiStatePreviewingDatasetFilesSelector), ) ), map(([ unsuitablePreviews, previewFiles ]) => uiActionSetPreviewingDatasetFiles({ previewingDatasetFiles: previewFiles.filter( ({ datasetId: dsId, filename: fName }) => !unsuitablePreviews.some( ({ datasetId, filename }) => datasetId === dsId && fName === filename ) ) })) ) @Effect() resetConnectivityMode: Observable<any> = this.store$.pipe( select(viewerStateSelectedRegionsSelector), pairwise(), filter(([o, n]) => o.length > 0 && n.length === 0), mapTo( ngViewerActionClearView({ payload: { 'Connectivity': false } }) ) ) constructor( private store$: Store<any>, @Inject(GET_KGDS_PREVIEW_INFO_FROM_ID_FILENAME) private getDatasetPreviewFromId: (arg) => Observable<any|null> ){ } } @Injectable({ providedIn: 'root' }) export class DatasetPreviewGlue implements IDatasetPreviewGlue, OnDestroy{ static readonly DEFAULT_DIALOG_OPTION = { ariaLabel: ARIA_LABELS.DATASET_FILE_PREVIEW, hasBackdrop: false, disableClose: true, autoFocus: false, panelClass: 'mat-card-sm', height: '50vh', width: '350px', position: { left: '5px' }, } static GetDatasetPreviewId(data: IDatasetPreviewData ){ const { datasetSchema = 'minds/core/dataset/v1.0.0', datasetId, filename } = data return `${datasetSchema}/${datasetId}:${filename}` } static GetDatasetPreviewFromId(id: string): IDatasetPreviewData{ const re = /([a-f0-9-]+):(.+)$/.exec(id) if (!re) throw new Error(`id cannot be decoded: ${id}`) return { datasetId: re[1], filename: re[2] } } static PreviewFileIsInCorrectSpace(previewFile, templateSelected): boolean{ const re = getKgSchemaIdFromFullId( (templateSelected && templateSelected.fullId) || '' ) const templateId = re && re[0] && `${re[0]}/${re[1]}` const { referenceSpaces } = previewFile return referenceSpaces.findIndex(({ fullId }) => fullId === '*' || fullId === templateId) >= 0 } private subscriptions: Subscription[] = [] private openedPreviewMap = new Map<string, {id: string, matDialogRef: MatDialogRef<any>}>() private previewingDatasetFiles$: Observable<IDatasetPreviewData[]> = this.store$.pipe( select(glueSelectorGetUiStatePreviewingFiles), startWith([]), shareReplay(1), ) private diffPreviewingDatasetFiles$= this.previewingDatasetFiles$.pipe( debounceTime(100), startWith([] as IDatasetPreviewData[]), pairwise(), map(([ oldPreviewWidgets, newPreviewWidgets ]) => { const oldPrvWgtIdSet = new Set(oldPreviewWidgets.map(DatasetPreviewGlue.GetDatasetPreviewId)) const newPrvWgtIdSet = new Set(newPreviewWidgets.map(DatasetPreviewGlue.GetDatasetPreviewId)) const prvToShow = newPreviewWidgets.filter(obj => !oldPrvWgtIdSet.has(DatasetPreviewGlue.GetDatasetPreviewId(obj))) const prvToDismiss = oldPreviewWidgets.filter(obj => !newPrvWgtIdSet.has(DatasetPreviewGlue.GetDatasetPreviewId(obj))) return { prvToShow, prvToDismiss } }), ) ngOnDestroy(){ while(this.subscriptions.length > 0){ this.subscriptions.pop().unsubscribe() } } private sharedDiffObs$ = this.diffPreviewingDatasetFiles$.pipe( switchMap(({ prvToShow, prvToDismiss }) => { return forkJoin({ prvToShow: prvToShow.length > 0 ? forkJoin(prvToShow.map(val => this.getDatasetPreviewFromId(val))) : of([]), prvToDismiss: prvToDismiss.length > 0 ? forkJoin(prvToDismiss.map(val => this.getDatasetPreviewFromId(val))) : of([]) }) }), map(prvFilterNull), shareReplay(1) ) private getDiffDatasetFilesPreviews(filterFn: (prv: any) => boolean = () => true): Observable<{prvToShow: any[], prvToDismiss: any[]}>{ return this.sharedDiffObs$.pipe( map(({ prvToDismiss, prvToShow }) => { return { prvToShow: prvToShow.filter(filterFn), prvToDismiss: prvToDismiss.filter(filterFn), } }) ) } public selectedRegionPreview$ = this.store$.pipe( select(state => state?.viewerState?.regionsSelected), filter(regions => !!regions), map(regions => /** effectively flatMap */ regions.reduce((acc, curr) => acc.concat( curr.originDatasets && Array.isArray(curr.originDatasets) && curr.originDatasets.length > 0 ? curr.originDatasets : [] ), [])), ) public onRegionSelectChangeShowPreview$ = this.selectedRegionPreview$.pipe( switchMap(arr => arr.length > 0 ? forkJoin(arr.map(({ kgId, kgSchema, filename }) => this.getDatasetPreviewFromId({ datasetId: kgId, datasetSchema: kgSchema, filename }))) : of([]) ), map(arr => arr.filter(item => !!item)), shareReplay(1), ) public onRegionDeselectRemovePreview$ = this.onRegionSelectChangeShowPreview$.pipe( pairwise(), map(([oArr, nArr]) => oArr.filter((item: any) => { return !nArr .map(DatasetPreviewGlue.GetDatasetPreviewId) .includes( DatasetPreviewGlue.GetDatasetPreviewId(item) ) })), ) public onClearviewRemovePreview$ = this.onRegionSelectChangeShowPreview$.pipe( filter(arr => arr.length > 0), switchMap(arr => this.store$.pipe( select(ngViewerSelectorClearView), distinctUntilChanged(), filter(val => val), mapTo(arr) )), ) public onClearviewAddPreview$ = this.onRegionSelectChangeShowPreview$.pipe( filter(arr => arr.length > 0), switchMap(arr => this.store$.pipe( select(ngViewerSelectorClearView), distinctUntilChanged(), filter(val => !val), skip(1), mapTo(arr) )) ) private fetchedDatasetPreviewCache: Map<string, Observable<any>> = new Map() public getDatasetPreviewFromId({ datasetSchema = 'minds/core/dataset/v1.0.0', datasetId, filename }: IDatasetPreviewData){ const dsPrvId = DatasetPreviewGlue.GetDatasetPreviewId({ datasetSchema, datasetId, filename }) const cachedPrv$ = this.fetchedDatasetPreviewCache.get(dsPrvId) const filteredDsId = /[a-f0-9-]+$/.exec(datasetId) if (cachedPrv$) return cachedPrv$ const url = `${DS_PREVIEW_URL}/${encodeURIComponent(datasetSchema)}/${filteredDsId}/${encodeURIComponent(filename)}` const filedetail$ = this.http.get(url, { responseType: 'json' }).pipe( map(json => { return { ...json, filename, datasetId, datasetSchema } }), catchError((_err, _obs) => of(null)) ) this.fetchedDatasetPreviewCache.set(dsPrvId, filedetail$) return filedetail$.pipe( tap(val => this.fetchedDatasetPreviewCache.set(dsPrvId, of(val))) ) } constructor( private store$: Store<any>, private http: HttpClient, private layersService: NgLayersService, @Optional() @Inject(ACTION_TO_WIDGET_TOKEN) private actionOnWidget: TypeActionToWidget<any> ){ if (!this.actionOnWidget) console.warn(`actionOnWidget not provided in DatasetPreviewGlue. Did you forget to provide it?`) // managing dataset files preview requiring an UI this.subscriptions.push( this.getDiffDatasetFilesPreviews( dsPrv => !PREVIEW_FILE_TYPES_NO_UI.includes(determinePreviewFileType(dsPrv)) ).subscribe(({ prvToDismiss: prvWgtToDismiss, prvToShow: prvWgtToShow }) => { for (const obj of prvWgtToShow) { this.openDatasetPreviewWidget(obj) } for (const obj of prvWgtToDismiss) { this.closeDatasetPreviewWidget(obj) } }) ) // managing dataset previews without UI // managing registeredVolumes this.subscriptions.push( this.getDiffDatasetFilesPreviews( dsPrv => determinePreviewFileType(dsPrv) === EnumPreviewFileTypes.VOLUMES ).pipe( withLatestFrom(this.store$.pipe( select(state => state?.viewerState?.templateSelected || null), distinctUntilChanged(), )) ).subscribe(([ { prvToShow, prvToDismiss }, templateSelected ]) => { const filterdPrvs = prvToShow.filter(prv => DatasetPreviewGlue.PreviewFileIsInCorrectSpace(prv, templateSelected)) for (const prv of filterdPrvs) { const { volumes } = prv['data']['iav-registered-volumes'] this.store$.dispatch(ngViewerActionAddNgLayer({ layer: volumes })) } for (const prv of prvToDismiss) { const { volumes } = prv['data']['iav-registered-volumes'] this.store$.dispatch(ngViewerActionRemoveNgLayer({ layer: volumes })) } }) ) // managing niftiVolumes // monitors previewDatasetFile obs to add/remove ng layer this.subscriptions.push( merge( this.getDiffDatasetFilesPreviews( dsPrv => determinePreviewFileType(dsPrv) === EnumPreviewFileTypes.NIFTI ), this.onRegionSelectChangeShowPreview$.pipe( map(prvToShow => ({ prvToShow, prvToDismiss: [] })) ), this.onRegionDeselectRemovePreview$.pipe( map(prvToDismiss => ({ prvToShow: [], prvToDismiss })) ), this.onClearviewRemovePreview$.pipe( map(prvToDismiss => ({ prvToDismiss, prvToShow: [] })) ), this.onClearviewAddPreview$.pipe( map(prvToShow => ({ prvToDismiss: [], prvToShow })) ) ).pipe( map(prvFilterNull), bufferTime(15), map(arr => { const prvToDismiss = [] const prvToShow = [] const showPrvIds = new Set() const dismissPrvIds = new Set() for (const { prvToDismiss: dismisses, prvToShow: shows } of arr) { for (const dismiss of dismisses) { const id = DatasetPreviewGlue.GetDatasetPreviewId(dismiss) if (!dismissPrvIds.has(id)) { dismissPrvIds.add(id) prvToDismiss.push(dismiss) } } for (const show of shows) { const id = DatasetPreviewGlue.GetDatasetPreviewId(show) if (!dismissPrvIds.has(id) && !showPrvIds.has(id)) { showPrvIds.add(id) prvToShow.push(show) } } } return { prvToDismiss, prvToShow } }), withLatestFrom(this.store$.pipe( select(state => state?.viewerState?.templateSelected || null), distinctUntilChanged(), )) ).subscribe(([ { prvToShow, prvToDismiss }, templateSelected ]) => { // TODO consider where to check validity of previewed nifti file for (const prv of prvToShow) { const { url, filename, name, volumeMetadata = {} } = prv const { min, max, colormap = EnumColorMapName.VIRIDIS } = volumeMetadata || {} const previewFileId = DatasetPreviewGlue.GetDatasetPreviewId(prv) const shaderObj = { ...PMAP_DEFAULT_CONFIG, ...{ colormap }, ...( typeof min !== 'undefined' ? { lowThreshold: min } : {} ), ...( max ? { highThreshold: max } : { highThreshold: 1 } ) } const layer = { // name: filename, name: name || filename, id: previewFileId, source : `nifti://${url}`, mixability : 'nonmixable', shader : getShader(shaderObj), annotation: `${DATASET_PREVIEW_ANNOTATION} ${filename}` } const { name: layerName } = layer const { colormap: cmap, lowThreshold, highThreshold, removeBg } = shaderObj this.layersService.highThresholdMap.set(layerName, highThreshold) this.layersService.lowThresholdMap.set(layerName, lowThreshold) this.layersService.colorMapMap.set(layerName, cmap) this.layersService.removeBgMap.set(layerName, removeBg) this.store$.dispatch( ngViewerActionAddNgLayer({ layer }) ) } for (const prv of prvToDismiss) { const { url, filename, name } = prv const previewFileId = DatasetPreviewGlue.GetDatasetPreviewId(prv) const layer = { name: name || filename, id: previewFileId, source : `nifti://${url}`, mixability : 'nonmixable', shader : getShader(PMAP_DEFAULT_CONFIG), annotation: `${DATASET_PREVIEW_ANNOTATION} ${filename}` } this.store$.dispatch( ngViewerActionRemoveNgLayer({ layer }) ) } if (prvToShow.length > 0) this.store$.dispatch(uiStateShowBottomSheet({ bottomSheetTemplate: null })) }) ) // monitors ngViewerStateLayers, and if user removes, also remove dataset preview, if exists this.subscriptions.push( this.store$.pipe( select(state => state?.ngViewerState?.layers || []), distinctUntilChanged(), pairwise(), map(([o, n]: [INgLayerInterface[], INgLayerInterface[]]) => { const nNameSet = new Set(n.map(({ name }) => name)) const oNameSet = new Set(o.map(({ name }) => name)) return { add: n.filter(({ name: nName }) => !oNameSet.has(nName)), remove: o.filter(({ name: oName }) => !nNameSet.has(oName)), } }), map(({ remove }) => remove), ).subscribe(layers => { for (const layer of layers) { const { id } = layer if (!id) return console.warn(`monitoring ngViewerStateLayers id is undefined`) try { const { datasetId, filename } = DatasetPreviewGlue.GetDatasetPreviewFromId(layer.id) this.store$.dispatch( glueActionRemoveDatasetPreview({ datasetPreviewFile: { filename, datasetId } }) ) } catch (e) { console.warn(`monitoring ngViewerStateLayers parsing id or dispatching action failed`, e) } } }) ) } private closeDatasetPreviewWidget(data: IDatasetPreviewData){ const previewId = DatasetPreviewGlue.GetDatasetPreviewId(data) const { id:widgetId } = this.openedPreviewMap.get(previewId) if (!widgetId) return try { this.actionOnWidget( EnumActionToWidget.CLOSE, null, { id: widgetId } ) } catch (e) { // It is possible that widget is already closed by the time that the state is reflected // This happens when user closes the dialog } this.openedPreviewMap.delete(previewId) } private openDatasetPreviewWidget(data: IDatasetPreviewData) { const { datasetId: kgId, filename } = data if (!!this.actionOnWidget) { const previewId = DatasetPreviewGlue.GetDatasetPreviewId(data) const onClose = () => { this.store$.dispatch( glueActionRemoveDatasetPreview({ datasetPreviewFile: data }) ) } const allPreviewCWs = Array.from(this.openedPreviewMap).map(([key, { matDialogRef }]) => matDialogRef.componentInstance as PreviewComponentWrapper) let newUntouchedIndex = 0 while(allPreviewCWs.findIndex(({ touched, untouchedIndex }) => !touched && untouchedIndex === newUntouchedIndex) >= 0){ newUntouchedIndex += 1 } const { id:widgetId, matDialogRef } = this.actionOnWidget( EnumActionToWidget.OPEN, PreviewComponentWrapper, { data: { filename, kgId }, onClose, overrideMatDialogConfig: { ...DatasetPreviewGlue.DEFAULT_DIALOG_OPTION, position: { left: `${5 + (30 * newUntouchedIndex)}px` } } } ) const previewWrapper = (matDialogRef.componentInstance as PreviewComponentWrapper) previewWrapper.untouchedIndex = newUntouchedIndex this.openedPreviewMap.set(previewId, {id: widgetId, matDialogRef}) } } public datasetPreviewDisplayed(file: DatasetPreview, dataset?: IKgDataEntry){ return this.previewingDatasetFiles$.pipe( map(datasetPreviews => { const { filename, datasetId } = file const { fullId } = dataset || {} const { kgId } = getIdObj(fullId) || {} return datasetPreviews.findIndex(({ datasetId: dsId, filename: fName }) => { return (datasetId || kgId) === dsId && fName === filename }) >= 0 }) ) } public displayDatasetPreview(previewFile: DatasetPreview, dataset: IKgDataEntry){ const { filename, datasetId } = previewFile const { fullId } = dataset const { kgId, kgSchema } = getIdObj(fullId) const datasetPreviewFile = { datasetSchema: kgSchema, datasetId: datasetId || kgId, filename } this.store$.dispatch(glueActionToggleDatasetPreview({ datasetPreviewFile })) } } export function datasetPreviewMetaReducer(reducer: ActionReducer<any>): ActionReducer<any>{ return function (state, action) { switch(action.type) { case glueActionToggleDatasetPreview.type: { const previewingDatasetFiles = (state?.uiState?.previewingDatasetFiles || []) as IDatasetPreviewData[] const ids = new Set(previewingDatasetFiles.map(DatasetPreviewGlue.GetDatasetPreviewId)) const { datasetPreviewFile } = action as Action & { datasetPreviewFile: IDatasetPreviewData } const newId = DatasetPreviewGlue.GetDatasetPreviewId(datasetPreviewFile) if (ids.has(newId)) { const removeId = DatasetPreviewGlue.GetDatasetPreviewId(datasetPreviewFile) const filteredOpenedWidgets = previewingDatasetFiles.filter(obj => { const id = DatasetPreviewGlue.GetDatasetPreviewId(obj) return id !== removeId }) return reducer(state, uiActionSetPreviewingDatasetFiles({ previewingDatasetFiles: filteredOpenedWidgets })) } else { return reducer(state, uiActionSetPreviewingDatasetFiles({ previewingDatasetFiles: [ ...previewingDatasetFiles, datasetPreviewFile ] })) } } case glueActionAddDatasetPreview.type: { const previewingDatasetFiles = (state?.uiState?.previewingDatasetFiles || []) as IDatasetPreviewData[] const { datasetPreviewFile } = action as Action & { datasetPreviewFile: IDatasetPreviewData } return reducer(state, uiActionSetPreviewingDatasetFiles({ previewingDatasetFiles: [ ...previewingDatasetFiles, datasetPreviewFile] })) } case glueActionRemoveDatasetPreview.type: { const previewingDatasetFiles = (state?.uiState?.previewingDatasetFiles || []) as IDatasetPreviewData[] const { datasetPreviewFile } = action as any const removeId = DatasetPreviewGlue.GetDatasetPreviewId(datasetPreviewFile) const filteredOpenedWidgets = previewingDatasetFiles.filter(obj => { const id = DatasetPreviewGlue.GetDatasetPreviewId(obj) return id !== removeId }) return reducer(state, uiActionSetPreviewingDatasetFiles({ previewingDatasetFiles: filteredOpenedWidgets })) } default: return reducer(state, action) } } } export const SAVE_USER_DATA = new InjectionToken<TypeSaveUserData>('SAVE_USER_DATA') type TypeSaveUserData = (key: string, value: string) => void export const gluActionFavDataset = createAction( '[glue] favDataset', props<{dataentry: Partial<IKgDataEntry>}>() ) export const gluActionUnfavDataset = createAction( '[glue] favDataset', props<{dataentry: Partial<IKgDataEntry>}>() ) export const gluActionToggleDataset = createAction( '[glue] favDataset', props<{dataentry: Partial<IKgDataEntry>}>() ) export const gluActionSetFavDataset = createAction( '[glue] favDataset', props<{dataentries: Partial<IKgDataEntry>[]}>() ) @Injectable({ providedIn: 'root' }) export class ClickInterceptorService{ private clickInterceptorStack: Function[] = [] removeInterceptor(fn: Function) { const idx = this.clickInterceptorStack.findIndex(int => int === fn) if (idx < 0) { console.warn(`clickInterceptorService could not remove the function. Did you pass the EXACT reference? Anonymouse functions such as () => {} or .bind will create a new reference! You may want to assign .bind to a ref, and pass it to register and unregister functions`) } else { this.clickInterceptorStack.splice(idx, 1) } } addInterceptor(fn: Function, config?: TClickInterceptorConfig) { if (config?.last) { this.clickInterceptorStack.push(fn) } else { this.clickInterceptorStack.unshift(fn) } } run(ev: any){ let intercepted = false for (const clickInc of this.clickInterceptorStack) { let runNext = false clickInc(ev, () => { runNext = true }) if (!runNext) { intercepted = true break } } if (!intercepted) this.fallback(ev) } fallback(_ev: any) { // called when the call has not been intercepted } }