diff --git a/docs/releases/v2.5.0.md b/docs/releases/v2.5.0.md index 5f9bcd52628076fb2cb360b74bf06cf9f11eb838..0f7dfbe92b89bba34755d8e89b1c1c16cc9c8660 100644 --- a/docs/releases/v2.5.0.md +++ b/docs/releases/v2.5.0.md @@ -1,5 +1,9 @@ # v2.5.0 +## New features + +- allow local NiFTi's to be visualised + ## Under the hood stuff - refactor: remove unneeded code diff --git a/src/dragDropFile/dragDrop.directive.ts b/src/dragDropFile/dragDrop.directive.ts index 0427836c4a369ddf3fd7ced09358639d206cdcf1..99e82c0c23b1cfbf957e74f872e37e7f3485a847 100644 --- a/src/dragDropFile/dragDrop.directive.ts +++ b/src/dragDropFile/dragDrop.directive.ts @@ -36,13 +36,14 @@ export class DragDropFileDirective implements OnInit, OnDestroy { ev.preventDefault() this.reset() - this.dragDropOnDrop.emit(Array.from(ev.dataTransfer.files)) + this.dragDropOnDrop.emit(Array.from(ev?.dataTransfer?.files || [])) } public reset() { if (this.snackbarRef) { this.snackbarRef.dismiss() } + this.snackbarRef = null this.opacity = null } @@ -54,7 +55,16 @@ export class DragDropFileDirective implements OnInit, OnDestroy { debounceTime(16), ).subscribe(flag => { if (flag) { - this.snackbarRef = this.snackBar.open(this.snackText || `Drop file(s) here.`) + this.snackbarRef = this.snackBar.open(this.snackText || `Drop file(s) here.`, 'Dismiss') + + /** + * In buggy scenarios, user could at least dismiss by action + */ + this.snackbarRef.afterDismissed().subscribe(reason => { + if (reason.dismissedByAction) { + this.reset() + } + }) this.opacity = 0.2 } else { this.reset() diff --git a/src/viewerModule/nehuba/module.ts b/src/viewerModule/nehuba/module.ts index afb3ea038ea52294bc5f2f0714023243c8cf0aae..63afffbfb47711024c899a8d845092e4531d1fbf 100644 --- a/src/viewerModule/nehuba/module.ts +++ b/src/viewerModule/nehuba/module.ts @@ -26,6 +26,7 @@ import { AuthModule } from "src/auth"; import {QuickTourModule} from "src/ui/quickTour/module"; import { WindowResizeModule } from "src/util/windowResize"; import { ViewerCtrlModule } from "./viewerCtrl"; +import { DragDropFileModule } from "src/dragDropFile/module"; @NgModule({ imports: [ @@ -41,6 +42,7 @@ import { ViewerCtrlModule } from "./viewerCtrl"; ShareModule, WindowResizeModule, ViewerCtrlModule, + DragDropFileModule, /** * should probably break this into its own... diff --git a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts index 0afa1fda6c3ad59fab421629150d6afa5c5aab81..89b72c6556b4d4773e5014a83aaed3bef6c19a2e 100644 --- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts +++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts @@ -1,27 +1,83 @@ import { CommonModule } from "@angular/common" -import { TestBed } from "@angular/core/testing" +import { Component, Directive } from "@angular/core" +import { async, ComponentFixture, TestBed } from "@angular/core/testing" +import { FormsModule, ReactiveFormsModule } from "@angular/forms" import { MockStore, provideMockStore } from "@ngrx/store/testing" -import { Subject } from "rxjs" +import { NEVER, of, Subject } from "rxjs" +import { ComponentsModule } from "src/components" import { ClickInterceptorService } from "src/glue" +import { LayoutModule } from "src/layouts/layout.module" import { PANELS } from "src/services/state/ngViewerState/constants" import { ngViewerSelectorOctantRemoval, ngViewerSelectorPanelMode, ngViewerSelectorPanelOrder } from "src/services/state/ngViewerState/selectors" import { uiStateMouseOverSegmentsSelector } from "src/services/state/uiState/selectors" -import { viewerStateSetSelectedRegions } from "src/services/state/viewerState/actions" +import { viewerStateSetSelectedRegions } from "src/services/state/viewerState.store.helper" import { viewerStateCustomLandmarkSelector, viewerStateNavigationStateSelector, viewerStateSelectedRegionsSelector } from "src/services/state/viewerState/selectors" -import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR } from "src/util" +import { Landmark2DModule } from "src/ui/nehubaContainer/2dLandmarks/module" +import { QuickTourModule } from "src/ui/quickTour" +import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module" +import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR, UtilModule } from "src/util" +import { WindowResizeModule } from "src/util/windowResize" import { NehubaLayerControlService } from "../layerCtrl.service" +import { MaximisePanelButton } from "../maximisePanelButton/maximisePanelButton.component" import { NehubaMeshService } from "../mesh.service" +import { NehubaViewerTouchDirective } from "../nehubaViewerInterface/nehubaViewerTouch.directive" +import { selectorAuxMeshes } from "../store" +import { TouchSideClass } from "../touchSideClass.directive" import { NehubaGlueCmp } from "./nehubaViewerGlue.component" +import { HarnessLoader } from "@angular/cdk/testing" + + +@Component({ + selector: 'viewer-ctrl-component', + template: '' +}) + +class MockViewerCtrlCmp{} + +@Directive({ + selector: '[iav-nehuba-viewer-container]', + exportAs: 'iavNehubaViewerContainer', +}) + +class MockNehubaViewerContainerDirective{ + public viewportToDatas: any + public nehubaViewerInstance = { + nehubaViewer: { + ngviewer: null + } + } + mouseOverSegments = NEVER + navigationEmitter = NEVER + mousePosEmitter = NEVER +} describe('> nehubaViewerGlue.component.ts', () => { let mockStore: MockStore - beforeEach(() => { + let rootLoader: HarnessLoader + let fixture: ComponentFixture<NehubaGlueCmp> + beforeEach( async(() => { TestBed.configureTestingModule({ imports: [ CommonModule, + AngularMaterialModule, + LayoutModule, + Landmark2DModule, + QuickTourModule, + ComponentsModule, + UtilModule, + WindowResizeModule, + FormsModule, + ReactiveFormsModule, ], declarations: [ - NehubaGlueCmp + NehubaGlueCmp, + MaximisePanelButton, + MockViewerCtrlCmp, + TouchSideClass, + + // TODO this may introduce a lot more dep + MockNehubaViewerContainerDirective, + NehubaViewerTouchDirective, ], providers: [ /** @@ -59,13 +115,8 @@ describe('> nehubaViewerGlue.component.ts', () => { } } ] - }).overrideComponent(NehubaGlueCmp, { - set: { - template: '', - templateUrl: null - } }).compileComponents() - }) + })) beforeEach(() => { mockStore = TestBed.inject(MockStore) @@ -76,11 +127,13 @@ describe('> nehubaViewerGlue.component.ts', () => { mockStore.overrideSelector(viewerStateSelectedRegionsSelector, []) mockStore.overrideSelector(uiStateMouseOverSegmentsSelector, []) mockStore.overrideSelector(viewerStateNavigationStateSelector, null) + + mockStore.overrideSelector(selectorAuxMeshes, []) }) it('> can be init', () => { - const fixture = TestBed.createComponent(NehubaGlueCmp) + fixture = TestBed.createComponent(NehubaGlueCmp) expect(fixture.componentInstance).toBeTruthy() }) @@ -170,4 +223,108 @@ describe('> nehubaViewerGlue.component.ts', () => { }) }) }) + + describe('> handleFileDrop', () => { + let addNgLayerSpy: jasmine.Spy + let removeNgLayersSpy: jasmine.Spy + let dummyFile1: File + let dummyFile2: File + let input: File[] + + beforeEach(() => { + dummyFile1 = (() => { + const bl: any = new Blob([], { type: 'text' }) + bl.name = 'filename1.txt' + bl.lastModifiedDate = new Date() + return bl as File + })() + + dummyFile2 = (() => { + const bl: any = new Blob([], { type: 'text' }) + bl.name = 'filename2.txt' + bl.lastModifiedDate = new Date() + return bl as File + })() + + fixture = TestBed.createComponent(NehubaGlueCmp) + fixture.detectChanges() + + addNgLayerSpy = spyOn(fixture.componentInstance['layerCtrlService'], 'addNgLayer').and.callFake(() => { + + }) + removeNgLayersSpy = spyOn(fixture.componentInstance['layerCtrlService'], 'removeNgLayers').and.callFake(() => { + + }) + }) + afterEach(() => { + addNgLayerSpy.calls.reset() + removeNgLayersSpy.calls.reset() + }) + + describe('> malformed input', () => { + const scenarios = [{ + desc: 'too few files', + inp: [] + }, { + desc: 'too many files', + inp: [dummyFile1, dummyFile2] + }] + + for (const { desc, inp } of scenarios) { + describe(`> ${desc}`, () => { + beforeEach(() => { + input = inp + + const cmp = fixture.componentInstance + cmp.handleFileDrop(input) + }) + + it('> should not call addnglayer', () => { + expect(removeNgLayersSpy).not.toHaveBeenCalled() + expect(addNgLayerSpy).not.toHaveBeenCalled() + }) + + // TODO having a difficult time getting snackbar harness + // it('> snackbar should show error message', async () => { + // console.log('get harness') + + // rootLoader = TestbedHarnessEnvironment.documentRootLoader(fixture) + // const loader = TestbedHarnessEnvironment.loader(fixture) + // fixture.detectChanges() + // const snackbarHarness = await rootLoader.getHarness(MatSnackBarHarness) + // console.log('got harness', snackbarHarness) + // // const message = await snackbarHarness.getMessage() + // // console.log('got message') + // // expect(message).toEqual(INVALID_FILE_INPUT) + // }) + }) + } + }) + + describe('> correct input', () => { + beforeEach(() => { + input = [dummyFile1] + + const cmp = fixture.componentInstance + cmp.handleFileDrop(input) + }) + + afterEach(() => { + // remove remove all urls + fixture.componentInstance['dismissAllAddedLayers']() + }) + + it('> should call addNgLayer', () => { + expect(removeNgLayersSpy).not.toHaveBeenCalled() + expect(addNgLayerSpy).toHaveBeenCalledTimes(1) + }) + it('> on repeated input, both remove nglayer and remove ng layer called', () => { + const cmp = fixture.componentInstance + cmp.handleFileDrop(input) + + expect(removeNgLayersSpy).toHaveBeenCalledTimes(1) + expect(addNgLayerSpy).toHaveBeenCalledTimes(2) + }) + }) + }) }) diff --git a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts index 4b203fcbf7c3bb734798e2d70cd29feeb4547b66..665d7a33b321f34d66f362c1a23492b7653351ec 100644 --- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts +++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts @@ -1,6 +1,6 @@ import { AfterViewInit, Component, ElementRef, EventEmitter, Inject, Input, OnChanges, OnDestroy, Optional, Output, SimpleChanges, ViewChild } from "@angular/core"; import { select, Store } from "@ngrx/store"; -import { asyncScheduler, combineLatest, fromEvent, merge, Observable, of, Subject } from "rxjs"; +import { asyncScheduler, combineLatest, fromEvent, merge, NEVER, Observable, of, Subject } from "rxjs"; import { ngViewerActionToggleMax } from "src/services/state/ngViewerState/actions"; import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR } from "src/util"; import { uiStateMouseOverSegmentsSelector } from "src/services/state/uiState/selectors"; @@ -23,9 +23,14 @@ import { MouseHoverDirective } from "src/mouseoverModule"; import { NehubaMeshService } from "../mesh.service"; import { IQuickTourData } from "src/ui/quickTour/constrants"; import { NehubaLayerControlService, IColorMap, SET_COLORMAP_OBS, SET_LAYER_VISIBILITY } from "../layerCtrl.service"; -import { switchMapWaitFor } from "src/util/fn"; +import { getUuid, switchMapWaitFor } from "src/util/fn"; import { INavObj } from "../navigation.service"; import { NG_LAYER_CONTROL, SET_SEGMENT_VISIBILITY } from "../layerCtrl.service/layerCtrl.util"; +import { MatSnackBar } from "@angular/material/snack-bar"; +import { getShader } from "src/util/constants"; +import { EnumColorMapName } from "src/util/colorMaps"; + +export const INVALID_FILE_INPUT = `Exactly one (1) nifti file is required!` @Component({ selector: 'iav-cmp-viewer-nehuba-glue', @@ -162,10 +167,10 @@ export class NehubaGlueCmp implements IViewer<'nehuba'>, OnChanges, OnDestroy, A this.setQuickTourPos() const { - mouseOverSegments, - navigationEmitter, - mousePosEmitter, - } = this.nehubaContainerDirective + mouseOverSegments = NEVER, + navigationEmitter = NEVER, + mousePosEmitter = NEVER, + } = this.nehubaContainerDirective || {} const sub = combineLatest([ mouseOverSegments, navigationEmitter, @@ -301,6 +306,7 @@ export class NehubaGlueCmp implements IViewer<'nehuba'>, OnChanges, OnDestroy, A private store$: Store<any>, private el: ElementRef, private log: LoggingService, + private snackbar: MatSnackBar, @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) clickInterceptor: ClickInterceptor, @Optional() @Inject(API_SERVICE_SET_VIEWER_HANDLE_TOKEN) setViewerHandle: TSetViewerHandle, @Optional() private layerCtrlService: NehubaLayerControlService, @@ -697,6 +703,56 @@ export class NehubaGlueCmp implements IViewer<'nehuba'>, OnChanges, OnDestroy, A return element } + private droppedLayerNames: { + layerName: string + resourceUrl: string + }[] = [] + private dismissAllAddedLayers(){ + while (this.droppedLayerNames.length) { + const { resourceUrl, layerName } = this.droppedLayerNames.pop() + this.layerCtrlService.removeNgLayers([ layerName ]) + URL.revokeObjectURL(resourceUrl) + } + } + public handleFileDrop(files: File[]){ + if (files.length !== 1) { + this.snackbar.open(INVALID_FILE_INPUT, 'Dismiss', { + duration: 5000 + }) + return + } + const randomUuid = getUuid() + const file = files[0] + + /** + * TODO check extension? + */ + + this.dismissAllAddedLayers() + + const url = URL.createObjectURL(file) + this.droppedLayerNames.push({ + layerName: randomUuid, + resourceUrl: url + }) + this.layerCtrlService.addNgLayer([{ + name: randomUuid, + mixability: 'mixable', + source: `nifti://${url}`, + shader: getShader({ + colormap: EnumColorMapName.MAGMA + }) + }]) + + this.snackbar.open( + `Viewing ${file.name}`, + 'Clear', + { duration: 0 } + ).afterDismissed().subscribe(() => { + this.dismissAllAddedLayers() + }) + } + public returnTruePos(quadrant: number, data: any) { const pos = quadrant > 2 diff --git a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.template.html b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.template.html index 83852858e81d2b7e6953310670cdd92e7d252b19..e8e372f6aa358b169a094e16baddb0384b3eeabc 100644 --- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.template.html +++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.template.html @@ -1,5 +1,6 @@ <div class="d-block w-100 h-100" (touchmove)="$event.preventDefault()" + (drag-drop-file)="handleFileDrop($event)" iav-viewer-touch-interface [iav-viewer-touch-interface-v-panels]="viewPanels" [iav-viewer-touch-interface-vp-to-data]="iavContainer?.viewportToDatas" @@ -168,7 +169,6 @@ <!-- NB must not lazy load. key listener needs to work even when component is not yet rendered --> <!-- stop propagation is needed, or else click will result in dismiss of menu --> <viewer-ctrl-component class="d-block m-2 ml-3 mr-3" - #viewerCtrlCmp="viewerCtrlCmp" (click)="$event.stopPropagation()"> </viewer-ctrl-component> </mat-menu>