diff --git a/.docker/Viewer-v3.x/default.conf.template b/.docker/Viewer-v3.x/default.conf.template index 354d5df16..91776166f 100644 --- a/.docker/Viewer-v3.x/default.conf.template +++ b/.docker/Viewer-v3.x/default.conf.template @@ -1,10 +1,16 @@ server { listen ${PORT}; - # listen 3000; location / { root /usr/share/nginx/html; index index.html index.htm; try_files $uri $uri/ /index.html; + add_header Cross-Origin-Opener-Policy same-origin; + add_header Cross-Origin-Embedder-Policy require-corp; + add_header Cross-Origin-Resource-Policy same-origin; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } error_page 500 502 503 504 /50x.html; location = /50x.html { diff --git a/.docker/Viewer-v3.x/entrypoint.sh b/.docker/Viewer-v3.x/entrypoint.sh index 4791c613a..400226413 100644 --- a/.docker/Viewer-v3.x/entrypoint.sh +++ b/.docker/Viewer-v3.x/entrypoint.sh @@ -1,6 +1,6 @@ #!/bin/sh -envsubst `${PORT}` < /usr/src/default.conf.template > /etc/nginx/conf.d/default.conf +envsubst '${PORT}' < /usr/src/default.conf.template > /etc/nginx/conf.d/default.conf if [ -n "$CLIENT_ID" ] || [ -n "$HEALTHCARE_API_ENDPOINT" ] then diff --git a/Dockerfile b/Dockerfile index efcf1ea28..aa62f5952 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,11 +63,10 @@ RUN yarn run build # which runs Nginx using Alpine Linux FROM nginxinc/nginx-unprivileged:1.23.1-alpine as final #RUN apk add --no-cache bash -ENV PORT=3000 +ENV PORT=80 RUN rm /etc/nginx/conf.d/default.conf USER nginx COPY --chown=nginx:nginx .docker/Viewer-v3.x /usr/src -RUN envsubst `${PORT}` < /usr/src/default.conf.template > /etc/nginx/conf.d/default.conf RUN chmod 777 /usr/src/entrypoint.sh COPY --from=builder /usr/src/app/platform/viewer/dist /usr/share/nginx/html ENTRYPOINT ["/usr/src/entrypoint.sh"] diff --git a/extensions/cornerstone-dicom-sr/package.json b/extensions/cornerstone-dicom-sr/package.json index 7c55901a1..a6b8e1abf 100644 --- a/extensions/cornerstone-dicom-sr/package.json +++ b/extensions/cornerstone-dicom-sr/package.json @@ -45,7 +45,7 @@ "dependencies": { "@babel/runtime": "7.16.3", "classnames": "^2.2.6", - "@cornerstonejs/core": "^0.21.5", - "@cornerstonejs/tools": "^0.29.8" + "@cornerstonejs/core": "^0.22.3", + "@cornerstonejs/tools": "^0.30.6" } } diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index 1a6cd8e13..d655625b1 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -43,9 +43,9 @@ }, "dependencies": { "@babel/runtime": "7.17.9", - "@cornerstonejs/core": "^0.21.5", - "@cornerstonejs/streaming-image-volume-loader": "^0.6.5", - "@cornerstonejs/tools": "^0.29.8", + "@cornerstonejs/core": "^0.22.3", + "@cornerstonejs/streaming-image-volume-loader": "^0.6.11", + "@cornerstonejs/tools": "^0.30.6", "@kitware/vtk.js": "25.9.0", "html2canvas": "^1.4.1", "lodash.debounce": "4.0.8", diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index 4fb056b78..fc19e384e 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -70,6 +70,7 @@ export default async function init({ DisplaySetService, UIDialogService, UIModalService, + UINotificationService, CineService, CornerstoneViewportService, HangingProtocolService, @@ -79,6 +80,15 @@ export default async function init({ window.services = servicesManager.services; + if (!window.crossOriginIsolated) { + UINotificationService.show({ + title: 'Cross Origin Isolation', + message: + 'Cross Origin Isolation is not enabled, volume rendering will not work (e.g., MPR)', + type: 'warning', + }); + } + if (cornerstone.getShouldUseCPURendering()) { _showCPURenderingModal(UIModalService, HangingProtocolService); } diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts index 559d0f069..54b88c2ae 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts @@ -1332,9 +1332,7 @@ class SegmentationService { segDisplaySetInstanceUID ); - const { - FrameOfReferenceUID: segFrameOfReferenceUID, - } = segDisplaySet.instance; + const segFrameOfReferenceUID = segDisplaySet.instance?.FrameOfReferenceUID; viewportDisplaySetInstanceUIDs.forEach(displaySetInstanceUID => { // check if the displaySet is sharing the same frameOfReferenceUID diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index ee1b49387..82d53805f 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -136,7 +136,7 @@ class CornerstoneViewportService implements IViewportService { public destroy() { this._removeResizeObserver(); this.viewportGridResizeObserver = null; - this.renderingEngine.destroy(); + this.renderingEngine?.destroy?.(); this.viewportsDisplaySets.clear(); this.renderingEngine = null; cache.purgeCache(); diff --git a/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx b/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx index cd03107aa..281e545fb 100644 --- a/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx +++ b/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx @@ -82,6 +82,7 @@ const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => {
{ setInstanceNumber(parseInt(value)); }} diff --git a/extensions/default/src/Panels/PanelStudyBrowser.tsx b/extensions/default/src/Panels/PanelStudyBrowser.tsx index 3129bb265..49f84f751 100644 --- a/extensions/default/src/Panels/PanelStudyBrowser.tsx +++ b/extensions/default/src/Panels/PanelStudyBrowser.tsx @@ -234,6 +234,7 @@ function PanelStudyBrowser({ return (
diff --git a/extensions/default/src/getCustomizationModule.tsx b/extensions/default/src/getCustomizationModule.tsx new file mode 100644 index 000000000..be0c6ac88 --- /dev/null +++ b/extensions/default/src/getCustomizationModule.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +/** + * + * Note: this is an example of how the customization module can be used + * using the customization module. Below, we are adding a new custom route + * to the application at the path /custom and rendering a custom component + * Real world use cases of the having a custom route would be to add a + * custom page for the user to view their profile, or to add a custom + * page for login etc. + */ +export default function getCustomizationModule() { + return [ + { + name: 'helloPage', + value: { + id: 'customRoutes', + routes: [ + { + path: '/custom', + children: () => ( +

Hello Custom Route

+ ), + }, + ], + }, + }, + ]; +} diff --git a/extensions/default/src/index.js b/extensions/default/src/index.js index ecbce1582..bb76b0afc 100644 --- a/extensions/default/src/index.js +++ b/extensions/default/src/index.js @@ -6,6 +6,7 @@ import getToolbarModule from './getToolbarModule'; import commandsModule from './commandsModule'; import getHangingProtocolModule from './getHangingProtocolModule'; import getStudiesForPatientByStudyInstanceUID from './Panels/getStudiesForPatientByStudyInstanceUID'; +import getCustomizationModule from './getCustomizationModule'; import { id } from './id.js'; import init from './init'; @@ -36,6 +37,8 @@ const defaultExtension = { }, ]; }, + + getCustomizationModule, }; export default defaultExtension; diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json index b070e4067..99ce2f3cc 100644 --- a/extensions/measurement-tracking/package.json +++ b/extensions/measurement-tracking/package.json @@ -32,8 +32,8 @@ "peerDependencies": { "@ohif/core": "^3.0.0", "classnames": "^2.2.6", - "@cornerstonejs/core": "^0.21.5", - "@cornerstonejs/tools": "^0.29.8", + "@cornerstonejs/core": "^0.22.3", + "@cornerstonejs/tools": "^0.30.6", "@ohif/extension-cornerstone-dicom-sr": "^3.0.0", "dcmjs": "^0.28.3", "prop-types": "^15.6.2", diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx index ea79c6be5..84bcfddc9 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx @@ -349,6 +349,7 @@ function PanelStudyBrowserTracking({ return ( { - const { ToolBarService, ToolGroupService } = servicesManager.services; + const { + MeasurementService, + ToolBarService, + ToolGroupService, + } = servicesManager.services; + + MeasurementService.clearMeasurements(); // Init Default and SR ToolGroups initToolGroups(extensionManager, ToolGroupService, commandsManager); @@ -120,20 +126,16 @@ function modeFactory() { const { ToolGroupService, SyncGroupService, - MeasurementService, ToolBarService, SegmentationService, CornerstoneViewportService, - HangingProtocolService, } = servicesManager.services; ToolBarService.reset(); - MeasurementService.clearMeasurements(); ToolGroupService.destroy(); SyncGroupService.destroy(); SegmentationService.destroy(); CornerstoneViewportService.destroy(); - HangingProtocolService.reset(); }, validationTags: { study: [], diff --git a/modes/tmtv/src/index.js b/modes/tmtv/src/index.js index fdbf697ed..cb927d6e2 100644 --- a/modes/tmtv/src/index.js +++ b/modes/tmtv/src/index.js @@ -136,21 +136,17 @@ function modeFactory({ modeConfiguration }) { const { ToolGroupService, SyncGroupService, - MeasurementService, ToolBarService, SegmentationService, CornerstoneViewportService, - HangingProtocolService, } = servicesManager.services; unsubscriptions.forEach(unsubscribe => unsubscribe()); ToolBarService.reset(); - MeasurementService.clearMeasurements(); ToolGroupService.destroy(); SyncGroupService.destroy(); SegmentationService.destroy(); CornerstoneViewportService.destroy(); - HangingProtocolService.reset(); }, validationTags: { study: [], diff --git a/modes/tmtv/src/utils/setFusionActiveVolume.js b/modes/tmtv/src/utils/setFusionActiveVolume.js index c7dbe20d4..44790eebe 100644 --- a/modes/tmtv/src/utils/setFusionActiveVolume.js +++ b/modes/tmtv/src/utils/setFusionActiveVolume.js @@ -32,14 +32,17 @@ export default function setFusionActiveVolume( toolNames.EllipticalROI ); + // Todo: this should not take into account the loader id + const volumeId = `cornerstoneStreamingImageVolume:${displaySets[0].displaySetInstanceUID}`; + const windowLevelConfig = { ...wlToolConfig, - volumeId: displaySets[0].displaySetInstanceUID, + volumeId, }; const ellipticalROIConfig = { ...ellipticalToolConfig, - volumeId: displaySets[0].displaySetInstanceUID, + volumeId, }; ToolGroupService.setToolConfiguration( diff --git a/platform/core/src/extensions/ExtensionManager.js b/platform/core/src/extensions/ExtensionManager.js index d7c93fe32..e72c72233 100644 --- a/platform/core/src/extensions/ExtensionManager.js +++ b/platform/core/src/extensions/ExtensionManager.js @@ -40,13 +40,12 @@ export default class ExtensionManager { _extensionLifeCycleHooks, } = this; - const { - MeasurementService, - ViewportGridService, - } = _servicesManager.services; - - MeasurementService.clearMeasurements(); - ViewportGridService.reset(); + // The onModeEnter of the service must occur BEFORE the extension + // onModeEnter in order to reset the state to a standard state + // before the extension restores and cached data. + for (const service of Object.values(_servicesManager.services)) { + service?.onModeEnter?.(); + } registeredExtensionIds.forEach(extensionId => { const onModeEnter = _extensionLifeCycleHooks.onModeEnter[extensionId]; @@ -69,14 +68,6 @@ export default class ExtensionManager { _extensionLifeCycleHooks, } = this; - const { - MeasurementService, - ViewportGridService, - } = _servicesManager.services; - - MeasurementService.clearMeasurements(); - ViewportGridService.reset(); - registeredExtensionIds.forEach(extensionId => { const onModeExit = _extensionLifeCycleHooks.onModeExit[extensionId]; @@ -87,6 +78,16 @@ export default class ExtensionManager { }); } }); + + // The service onModeExit calls must occur after the extension ones + // so that extension ones can store/restore data. + for (const service of Object.values(_servicesManager.services)) { + try { + service?.onModeExit?.(); + } catch (e) { + console.warn('onModeExit caught', e); + } + } } /** @@ -200,6 +201,7 @@ export default class ExtensionManager { case MODULE_TYPES.SOP_CLASS_HANDLER: case MODULE_TYPES.CONTEXT: case MODULE_TYPES.LAYOUT_TEMPLATE: + case MODULE_TYPES.CUSTOMIZATION: case MODULE_TYPES.UTILITY: // Default for most extension points, // Just adds each entry ready for consumption by mode. diff --git a/platform/core/src/extensions/ExtensionManager.test.js b/platform/core/src/extensions/ExtensionManager.test.js index 1ecf778ad..47c737518 100644 --- a/platform/core/src/extensions/ExtensionManager.test.js +++ b/platform/core/src/extensions/ExtensionManager.test.js @@ -235,6 +235,9 @@ describe('ExtensionManager.js', () => { getUtilityModule: () => { return [{}]; }, + getCustomizationModule: () => { + return [{}]; + }, }; await extensionManager.registerExtension(extension); diff --git a/platform/core/src/extensions/MODULE_TYPES.js b/platform/core/src/extensions/MODULE_TYPES.js index a0627c9ba..c96c3f1fb 100644 --- a/platform/core/src/extensions/MODULE_TYPES.js +++ b/platform/core/src/extensions/MODULE_TYPES.js @@ -1,5 +1,6 @@ export default { COMMANDS: 'commandsModule', + CUSTOMIZATION: 'customizationModule', DATA_SOURCE: 'dataSourcesModule', PANEL: 'panelModule', SOP_CLASS_HANDLER: 'sopClassHandlerModule', diff --git a/platform/core/src/index.test.js b/platform/core/src/index.test.js index fdc3b60a5..cd07dfd60 100644 --- a/platform/core/src/index.test.js +++ b/platform/core/src/index.test.js @@ -24,6 +24,7 @@ describe('Top level exports', () => { 'OHIF', // 'CineService', + 'CustomizationServiceRegistration', 'UIDialogService', 'UIModalService', 'UINotificationService', diff --git a/platform/core/src/index.ts b/platform/core/src/index.ts index c5adb427e..9abc4e1e3 100644 --- a/platform/core/src/index.ts +++ b/platform/core/src/index.ts @@ -27,6 +27,7 @@ import { HangingProtocolService, pubSubServiceInterface, UserAuthenticationService, + CustomizationServiceRegistration, } from './services'; import IWebApiDataSource from './DataSources/IWebApiDataSource'; @@ -57,6 +58,7 @@ const OHIF = { viewer: {}, // CineService, + CustomizationServiceRegistration, UIDialogService, UIModalService, UINotificationService, @@ -92,6 +94,7 @@ export { DICOMWeb, // CineService, + CustomizationServiceRegistration, UIDialogService, UIModalService, UINotificationService, diff --git a/platform/core/src/services/CustomizationService/CustomizationService.test.js b/platform/core/src/services/CustomizationService/CustomizationService.test.js new file mode 100644 index 000000000..959816ea5 --- /dev/null +++ b/platform/core/src/services/CustomizationService/CustomizationService.test.js @@ -0,0 +1,169 @@ +import CustomizationService from './CustomizationService'; +import log from '../../log'; + +jest.mock('../../log.js', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +})); + +const extensionManager = { + registeredExtensionIds: [], + moduleEntries: {}, + + getModuleEntry: function (id) { + return this.moduleEntries[id]; + }, +}; + +const commandsManager = {}; + +const ohifOverlayItem = { + id: 'ohif.overlayItem', + content: function (props) { + return { + label: this.label, + value: props[this.attribute], + ver: 'default', + }; + }, +}; + +const testItem = { + id: 'testItem', + customizationType: 'ohif.overlayItem', + attribute: 'testAttribute', + label: 'testItemLabel', +}; + +describe('CustomizationService.ts', () => { + let customizationService; + + let configuration; + + beforeEach(() => { + log.warn.mockClear(); + jest.clearAllMocks(); + configuration = {}; + customizationService = new CustomizationService({ + configuration, + commandsManager, + }); + }); + + describe('init', () => { + it('init succeeds', () => { + customizationService.init(extensionManager); + }); + + it('configurationRegistered', () => { + configuration.testItem = testItem; + customizationService.init(extensionManager); + expect(customizationService.getGlobalCustomization('testItem')).toBe( + testItem + ); + }); + + it('defaultRegistered', () => { + extensionManager.registeredExtensionIds.push('@testExtension'); + extensionManager.moduleEntries[ + '@testExtension.customizationModule.default' + ] = { name: 'default', value: [testItem] }; + customizationService.init(extensionManager); + expect(customizationService.getGlobalCustomization('testItem')).toBe( + testItem + ); + }); + }); + + describe('customizationType', () => { + it('inherits type', () => { + extensionManager.registeredExtensionIds.push('@testExtension'); + extensionManager.moduleEntries[ + '@testExtension.customizationModule.default' + ] = { name: 'default', value: [ohifOverlayItem] }; + configuration.testItem = testItem; + customizationService.init(extensionManager); + + const item = customizationService.getGlobalCustomization('testItem'); + + const props = { testAttribute: 'testAttrValue' }; + const result = item.content(props); + expect(result.label).toBe(testItem.label); + expect(result.value).toBe(props.testAttribute); + expect(result.ver).toBe('default'); + }); + + it('inline default inherits type', () => { + extensionManager.registeredExtensionIds.push('@testExtension'); + extensionManager.moduleEntries[ + '@testExtension.customizationModule.default' + ] = { name: 'default', value: [ohifOverlayItem] }; + configuration.testItem = testItem; + customizationService.init(extensionManager); + + const item = customizationService.getGlobalCustomization('testItem2', { + id: 'testItem2', + customizationType: 'ohif.overlayItem', + label: 'otherLabel', + attribute: 'otherAttr', + }); + + // Customizes the default value, as this is testItem2 + const props = { otherAttr: 'other attribute value' }; + const result = item.content(props); + expect(result.label).toBe('otherLabel'); + expect(result.value).toBe(props.otherAttr); + expect(result.ver).toBe('default'); + }); + }); + + describe('mode customization', () => { + it('onModeEnter can add extensions', () => { + extensionManager.registeredExtensionIds.push('@testExtension'); + extensionManager.moduleEntries[ + '@testExtension.customizationModule.default' + ] = { name: 'default', value: [ohifOverlayItem] }; + customizationService.init(extensionManager); + + expect( + customizationService.getModeCustomization('testItem') + ).toBeUndefined(); + + customizationService.addModeCustomizations([testItem]); + + expect( + customizationService.getGlobalCustomization('testItem') + ).toBeUndefined(); + + const item = customizationService.getModeCustomization('testItem'); + + const props = { testAttribute: 'testAttrValue' }; + const result = item.content(props); + expect(result.label).toBe(testItem.label); + expect(result.value).toBe(props.testAttribute); + expect(result.ver).toBe('default'); + }); + + it('global customizations override modes', () => { + extensionManager.registeredExtensionIds.push('@testExtension'); + extensionManager.moduleEntries[ + '@testExtension.customizationModule.default' + ] = { name: 'default', value: [ohifOverlayItem] }; + configuration.testItem = testItem; + customizationService.init(extensionManager); + + // Add a mode customization that would otherwise fail below + customizationService.addModeCustomizations([ + { ...testItem, label: 'other' }, + ]); + + const item = customizationService.getModeCustomization('testItem'); + + const props = { testAttribute: 'testAttrValue' }; + const result = item.content(props); + expect(result.label).toBe(testItem.label); + expect(result.value).toBe(props.testAttribute); + }); + }); +}); diff --git a/platform/core/src/services/CustomizationService/CustomizationService.ts b/platform/core/src/services/CustomizationService/CustomizationService.ts new file mode 100644 index 000000000..ee8a55ef4 --- /dev/null +++ b/platform/core/src/services/CustomizationService/CustomizationService.ts @@ -0,0 +1,264 @@ +import merge from 'lodash.merge'; +import { PubSubService } from '../_shared/pubSubServiceInterface'; +import { Customization, NestedStrings, Obj } from './types'; + +const EVENTS = { + MODE_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:modeModified', + GLOBAL_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:globalModified', +}; + +const flattenNestedStrings = ( + strs: NestedStrings | string, + ret?: Record +): Record => { + if (!ret) ret = {}; + if (!strs) return ret; + if (Array.isArray(strs)) { + for (const val of strs) { + flattenNestedStrings(val, ret); + } + } else { + ret[strs] = strs; + } + return ret; +}; + +/** + * The CustomizationService allows for retrieving of custom components + * and configuration for mode and global values. + * The intent of the items is to provide a react component. This can be + * done by straight out providing an entire react component or else can be + * done by configuring a react component, or configuring a part of a react + * component. These are intended to be fairly indistinguishable in use of + * it, although the internals of how that is implemented may need to know + * about the customization service. + * + * A customization value can be: + * 1. React function, taking (React, props) and returning a rendered component + * For example, createLogoComponentFn renders a component logo for display + * 2. Custom UI component configuration, as defined by the component which uses it. + * For example, context menus define a complex structure allowing site-determined + * context menus to be set. + * 3. A string name, being the extension id for retrieving one of the above. + * + * The default values for the extension come from the app_config value 'whiteLabeling', + * The whiteLabelling can have lists of extensions to load for the default global and + * mode extensions. These are: + * 'globalExtensions' which is a list of extension id's to load for global values + * 'modeExtensions' which is a list of extension id's to load for mode values + * They default to the list ['*'] if not otherwise provided, which means to check + * every module for the given id and to load it/add it to the extensions. + */ +export default class CustomizationService extends PubSubService { + commandsManager: Record; + extensionManager: Record; + + modeCustomizations: Record = {}; + globalCustomizations: Record = {}; + configuration: UICustomizationConfiguration; + + constructor({ configuration, commandsManager }) { + super(EVENTS); + this.commandsManager = commandsManager; + this.configuration = configuration || {}; + } + + public init(extensionManager: ExtensionManager): void { + this.extensionManager = extensionManager; + this.initDefaults(); + this.addReferences(this.configuration); + } + + initDefaults(): void { + this.extensionManager.registeredExtensionIds.forEach(extensionId => { + const key = `${extensionId}.customizationModule.default`; + const defaultCustomizations = this.findExtensionValue(key); + if (!defaultCustomizations) return; + const { value } = defaultCustomizations; + this.addReference(value, true); + }); + } + + findExtensionValue(value: string): Obj | void { + const entry = this.extensionManager.getModuleEntry(value); + return entry; + } + + public onModeEnter(): void { + super.reset(); + this.modeCustomizations = {}; + } + + /** + * + * @param {*} interaction - can be undefined to run nothing + * @param {*} extraOptions to include in the commands run + */ + recordInteraction( + interaction: Customization | void, + extraOptions?: Record + ): void { + if (!interaction) return; + const commandsManager = this.commandsManager; + const { commands = [] } = interaction; + + commands.forEach(({ commandName, commandOptions, context }) => { + if (commandName) { + commandsManager.runCommand( + commandName, + { + interaction, + ...commandOptions, + ...extraOptions, + }, + context + ); + } else { + console.warn('No command name supplied in', interaction); + } + }); + } + + public getModeCustomizations(): Record { + return this.modeCustomizations; + } + + public setModeCustomization( + customizationId: string, + customization: Customization + ): void { + this.modeCustomizations[customizationId] = merge( + this.modeCustomizations[customizationId] || {}, + customization + ); + this._broadcastEvent(this.EVENTS.CUSTOMIZATION_MODIFIED, { + buttons: this.modeCustomizations, + button: this.modeCustomizations[customizationId], + }); + } + + /** Mode customizations are changes to the behaviour of the extensions + * when running in a given mode. Reset clears mode customizations. + * Note that global customizations over-ride mode customizations. + * @param defautlValue to return if no customization specified. + */ + public getModeCustomization( + customizationId: string, + defaultValue?: Customization + ): Customization | void { + const customization = + this.globalCustomizations[customizationId] ?? + this.modeCustomizations[customizationId] ?? + defaultValue; + return this.applyType(customization); + } + + /** Applies any inheritance due to UI Type customization */ + public applyType(customization: Customization): Customization { + if (!customization) return customization; + const { customizationType } = customization; + if (!customizationType) return customization; + const parent = this.getModeCustomization(customizationType); + return parent + ? Object.assign(Object.create(parent), customization) + : customization; + } + + public addModeCustomizations(modeCustomizations): void { + if (!modeCustomizations) { + return; + } + this.addReferences(modeCustomizations, false); + + this._broadcastModeCustomizationModified(); + } + + _broadcastModeCustomizationModified(): void { + this._broadcastEvent(EVENTS.MODE_CUSTOMIZATION_MODIFIED, { + modeCustomizations: this.modeCustomizations, + globalCustomizations: this.globalCustomizations, + }); + } + + /** Global customizations are those that affect parts of the GUI other than + * the modes. They include things like settings for the search screen. + * Reset does NOT clear global customizations. + */ + getGlobalCustomization( + id: string, + defaultValue?: Customization + ): Customization | void { + return this.applyType(this.globalCustomizations[id] ?? defaultValue); + } + + setGlobalCustomization(id: string, value: Customization): void { + this.globalCustomizations[id] = value; + this._broadcastGlobalCustomizationModified(); + } + + protected setConfigGlobalCustomization( + configuration: AppConfigCustomization + ): void { + this.globalCustomizations = {}; + const keys = flattenNestedStrings(configuration.globalCustomizations); + this.readCustomizationTypes( + v => keys[v.name] && v.customization, + this.globalCustomizations + ); + + // TODO - iterate over customizations, loading them from the extension + // manager. + this._broadcastGlobalCustomizationModified(); + } + + _broadcastGlobalCustomizationModified(): void { + this._broadcastEvent(EVENTS.GLOBAL_CUSTOMIZATION_MODIFIED, { + modeCustomizations: this.modeCustomizations, + globalCustomizations: this.globalCustomizations, + }); + } + + /** + * A single reference is either an an array, or a single customization value, + * whose id is the id in the object, or the parent id. + * This allows for general use to register customizationModule entries. + */ + addReference( + value?: Obj | Obj[] | string, + isGlobal = true, + id?: string + ): void { + if (!value) return; + if (typeof value === 'string') { + const extensionValue = this.findExtensionValue(value); + this.addReferences(extensionValue); + } else if (Array.isArray(value)) { + this.addReferences(value, isGlobal); + } else { + const useId = value.id || id; + this[isGlobal ? 'setGlobalCustomization' : 'setModeCustomization']( + useId as string, + value + ); + } + } + + /** References are: + * list of customizations, added in order + * object containing a customization id and value + * This format allows for the original whitelist format. + */ + addReferences(references?: Obj | Obj[], isGlobal = true): void { + if (!references) return; + if (Array.isArray(references)) { + references.forEach(item => { + this.addReference(item, isGlobal); + }); + } else { + for (const key of Object.keys(references)) { + const value = references[key]; + this.addReference(value, isGlobal, key); + } + } + } +} diff --git a/platform/core/src/services/CustomizationService/index.ts b/platform/core/src/services/CustomizationService/index.ts new file mode 100644 index 000000000..d2eec2824 --- /dev/null +++ b/platform/core/src/services/CustomizationService/index.ts @@ -0,0 +1,11 @@ +import CustomizationService from './CustomizationService'; + +const CustomizationServiceRegistration = { + name: 'customizationService', + create: ({ configuration = {}, commandsManager }) => { + return new CustomizationService({ configuration, commandsManager }); + }, +}; + +export default CustomizationServiceRegistration; +export { CustomizationService, CustomizationServiceRegistration }; diff --git a/platform/core/src/services/CustomizationService/types.ts b/platform/core/src/services/CustomizationService/types.ts new file mode 100644 index 000000000..b9c382619 --- /dev/null +++ b/platform/core/src/services/CustomizationService/types.ts @@ -0,0 +1,39 @@ +import Command from '../../types/Command'; +import { ComponentType } from 'react'; + +export type Obj = Record; + +export interface BaseCustomization extends Obj { + id: string; + customizationType?: string; + description?: string; + label?: string; + commands?: Command[]; +} + +export interface LabelCustomization extends BaseCustomization { + label: string; +} + +export interface CodeCustomization extends BaseCustomization { + code: string; +} + +export interface CommandCustomization extends BaseCustomization { + commands: Command[]; +} + +export type Customization = + | BaseCustomization + | LabelCustomization + | CommandCustomization + | CodeCustomization; + +export default Customization; + +export type ComponentReturn = { + component: ComponentType; + props?: Obj; +}; + +export type NestedStrings = string[] | NestedStrings[]; diff --git a/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js b/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js index ce59aff66..4822b616c 100644 --- a/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js +++ b/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js @@ -49,7 +49,15 @@ function createStudyMetadata(StudyInstanceUID) { ); if (existingSeries) { - existingSeries.instances.push(...instances); + // Only add instances not already present, so generate a map + // of existing instances and filter the to add by things + // already present. + const sopMap = {}; + existingSeries.instances.forEach( + it => (sopMap[it.SOPInstanceUID] = it) + ); + const newInstances = instances.filter(it => !sopMap[it.SOPInstanceUID]); + existingSeries.instances.push(...newInstances); } else { const series = createSeriesMetadata(instances); this.series.push(series); diff --git a/platform/core/src/services/DisplaySetService/DisplaySetService.js b/platform/core/src/services/DisplaySetService/DisplaySetService.js index 2f4357bd9..da3813a3b 100644 --- a/platform/core/src/services/DisplaySetService/DisplaySetService.js +++ b/platform/core/src/services/DisplaySetService/DisplaySetService.js @@ -53,7 +53,12 @@ export default class DisplaySetService { const activeDisplaySets = this.activeDisplaySets; displaySets.forEach(displaySet => { - activeDisplaySets.push(displaySet); + // This test makes adding display sets an N^2 operation, so it might + // become important to do this in an efficient manner for large + // numbers of display sets. + if (!activeDisplaySets.includes(displaySet)) { + activeDisplaySets.push(displaySet); + } }); } @@ -194,6 +199,17 @@ export default class DisplaySetService { } }; + /** + * The onModeExit returns the display set service to the initial state, + * that is without any display sets. To avoid recreating display sets, + * the mode specific onModeExit is called before this method and should + * store the active display sets and the cached data. + */ + onModeExit() { + this.getDisplaySetCache().length = 0; + this.activeDisplaySets.length = 0; + } + makeDisplaySetForInstances(instancesSrc, settings) { let instances = instancesSrc; const instance = instances[0]; diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts index 216bf80bd..2a1a7230f 100644 --- a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts +++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts @@ -122,6 +122,11 @@ class HangingProtocolService { this.displaySetMatchDetails = new Map(); } + /** Leave the hanging protocol in the initialized state */ + public onModeExit() { + this.reset(); + } + public getActiveProtocol(): { protocol: HangingProtocol.Protocol; stage: number; diff --git a/platform/core/src/services/MeasurementService/MeasurementService.js b/platform/core/src/services/MeasurementService/MeasurementService.js index f7258941d..a0955e86a 100644 --- a/platform/core/src/services/MeasurementService/MeasurementService.js +++ b/platform/core/src/services/MeasurementService/MeasurementService.js @@ -570,6 +570,15 @@ class MeasurementService { this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED, { measurements }); } + /** + * Called after the mode.onModeExit is called to reset the state. + * To store measurements for later use, store them in the mode.onModeExit + * and restore them in the mode onModeEnter. + */ + onModeExit() { + this.clearMeasurements(); + } + jumpToMeasurement(viewportIndex, measurementUID) { const measurement = this.measurements[measurementUID]; diff --git a/platform/core/src/services/ToolBarService/ToolBarService.js b/platform/core/src/services/ToolBarService/ToolBarService.ts similarity index 91% rename from platform/core/src/services/ToolBarService/ToolBarService.js rename to platform/core/src/services/ToolBarService/ToolBarService.ts index 29f2e9f2b..908899f21 100644 --- a/platform/core/src/services/ToolBarService/ToolBarService.js +++ b/platform/core/src/services/ToolBarService/ToolBarService.ts @@ -52,11 +52,19 @@ export default class ToolBarService { this.buttons = {}; } + onModeEnter() { + this.reset(); + } + /** * - * @param {*} interaction + * @param {*} interaction - can be undefined to run nothing + * @param {*} options is an optional set of extra commandOptions + * used for calling the specified interaction. That is, the command is + * called with {...commandOptions,...options} */ - recordInteraction(interaction) { + recordInteraction(interaction, options) { + if (!interaction) return; const commandsManager = this._commandsManager; const { groupId, itemId, interactionType, commands } = interaction; @@ -64,7 +72,14 @@ export default class ToolBarService { case 'action': { commands.forEach(({ commandName, commandOptions, context }) => { if (commandName) { - commandsManager.runCommand(commandName, commandOptions, context); + commandsManager.runCommand( + commandName, + { + ...commandOptions, + ...options, + }, + context + ); } }); break; @@ -171,6 +186,10 @@ export default class ToolBarService { } } + getButton(id) { + return this.buttons[id]; + } + setButtons(buttons) { this.buttons = buttons; this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { diff --git a/platform/core/src/services/ToolBarService/index.js b/platform/core/src/services/ToolBarService/index.ts similarity index 100% rename from platform/core/src/services/ToolBarService/index.js rename to platform/core/src/services/ToolBarService/index.ts diff --git a/platform/core/src/services/ViewportGridService/ViewportGridService.ts b/platform/core/src/services/ViewportGridService/ViewportGridService.ts index 2cc193847..0700afa00 100644 --- a/platform/core/src/services/ViewportGridService/ViewportGridService.ts +++ b/platform/core/src/services/ViewportGridService/ViewportGridService.ts @@ -24,6 +24,7 @@ class ViewportGridService { restoreCachedLayout: restoreCachedLayoutImplementation, setLayout: setLayoutImplementation, reset: resetImplementation, + onModeExit: onModeExitImplementation, set: setImplementation, }) { if (getStateImplementation) { @@ -50,6 +51,9 @@ class ViewportGridService { if (restoreCachedLayoutImplementation) { this.serviceImplementation._restoreCachedLayout = restoreCachedLayoutImplementation; } + if (onModeExitImplementation) { + this.serviceImplementation._onModeExit = onModeExitImplementation; + } if (setImplementation) { this.serviceImplementation._set = setImplementation; } @@ -92,6 +96,16 @@ class ViewportGridService { this.serviceImplementation._reset(); } + /** + * The onModeExit must set the state of the viewport grid to a standard/clean + * state. To implement store/recover of the viewport grid, perform + * a state store in the mode or extension onModeExit, and recover that + * data if appropriate in the onModeEnter of the mode or extension. + */ + public onModeExit(): void { + this.serviceImplementation._onModeExit(); + } + public setCachedLayout({ cacheId, cachedLayout }) { this.serviceImplementation._setCachedLayout({ cacheId, cachedLayout }); } diff --git a/platform/core/src/services/_shared/pubSubServiceInterface.js b/platform/core/src/services/_shared/pubSubServiceInterface.js index 9a22a3ccb..855f07280 100644 --- a/platform/core/src/services/_shared/pubSubServiceInterface.js +++ b/platform/core/src/services/_shared/pubSubServiceInterface.js @@ -1,4 +1,5 @@ import guid from '../../utils/guid'; +import * as Types from '../../Types'; /** * Consumer must implement: @@ -24,7 +25,7 @@ function subscribe(eventName, callback) { const listenerId = guid(); const subscription = { id: listenerId, callback }; - console.info(`Subscribing to '${eventName}'.`); + // console.info(`Subscribing to '${eventName}'.`); if (Array.isArray(this.listeners[eventName])) { this.listeners[eventName].push(subscription); } else { @@ -86,3 +87,21 @@ function _broadcastEvent(eventName, callbackProps) { }); } } + +/** Export a PubSubService class to be used instead of the individual items */ +export class PubSubService { + constructor(EVENTS) { + this.EVENTS = EVENTS; + this.subscribe = subscribe; + this._broadcastEvent = _broadcastEvent; + this._unsubscribe = _unsubscribe; + this._isValidEvent = _isValidEvent; + this.listeners = {}; + this.unsubscriptions = []; + } + + reset() { + this.unsubscriptions.forEach(unsub => unsub()); + this.unsubscriptions = []; + } +} diff --git a/platform/core/src/services/index.js b/platform/core/src/services/index.js index 87f714815..01605f407 100644 --- a/platform/core/src/services/index.js +++ b/platform/core/src/services/index.js @@ -10,12 +10,20 @@ import ToolBarService from './ToolBarService'; import ViewportGridService from './ViewportGridService'; import CineService from './CineService'; import HangingProtocolService from './HangingProtocolService'; -import pubSubServiceInterface from './_shared/pubSubServiceInterface'; +import pubSubServiceInterface, { + PubSubService, +} from './_shared/pubSubServiceInterface'; import UserAuthenticationService from './UserAuthenticationService'; +import { + CustomizationService, + CustomizationServiceRegistration, +} from './CustomizationService'; export { MeasurementService, ServicesManager, + CustomizationService, + CustomizationServiceRegistration, UIDialogService, UIModalService, UINotificationService, @@ -27,5 +35,6 @@ export { HangingProtocolService, CineService, pubSubServiceInterface, + PubSubService, UserAuthenticationService, }; diff --git a/platform/core/src/types/Command.ts b/platform/core/src/types/Command.ts new file mode 100644 index 000000000..42d75f63b --- /dev/null +++ b/platform/core/src/types/Command.ts @@ -0,0 +1,7 @@ +export interface Command { + commandName: string; + commandOptions?: Record; + context?: string; +} + +export default Command; diff --git a/platform/core/src/types/index.ts b/platform/core/src/types/index.ts index 80351c953..9e1f79557 100644 --- a/platform/core/src/types/index.ts +++ b/platform/core/src/types/index.ts @@ -5,13 +5,21 @@ import { } from './StudyMetadata'; import Consumer from './Consumer'; - +import { ExtensionManager } from '../extensions'; +import { CustomizationService, PubSubService } from '../services'; import * as HangingProtocol from './HangingProtocol'; +import Command from './Command'; + +export * from '../services/CustomizationService/types'; export type { + ExtensionManager, HangingProtocol, StudyMetadata, SeriesMetadata, InstanceMetadata, Consumer, + PubSubService, + CustomizationService, + Command, }; diff --git a/platform/core/src/utils/metadataProvider/fetchPaletteColorLookupTableData.js b/platform/core/src/utils/metadataProvider/fetchPaletteColorLookupTableData.js index e79749958..54cdff170 100644 --- a/platform/core/src/utils/metadataProvider/fetchPaletteColorLookupTableData.js +++ b/platform/core/src/utils/metadataProvider/fetchPaletteColorLookupTableData.js @@ -12,16 +12,16 @@ * Returns undefined if the palette data is absent. */ export default function fetchPaletteColorLookupTableData( - item, tag, descriptorTag + item, + tag, + descriptorTag ) { const { PaletteColorLookupTableUID } = item; const paletteData = item[tag]; - if (paletteData === undefined && PaletteColorLookupTableUID === undefined) return; + if (paletteData === undefined && PaletteColorLookupTableUID === undefined) + return; // performance optimization - read UID and cache by UID - return _getPaletteColor( - item[tag], - item[descriptorTag] - ) + return _getPaletteColor(item[tag], item[descriptorTag]); } function _getPaletteColor(paletteColorLookupTableData, lutDescriptor) { @@ -36,13 +36,12 @@ function _getPaletteColor(paletteColorLookupTableData, lutDescriptor) { if (bits === 16) { let j = 0; for (let i = 0; i < numLutEntries; i++) { - lut[i] = arraybuffer[j++] + arraybuffer[j++] << 8; + lut[i] = (arraybuffer[j++] + arraybuffer[j++]) << 8; } } else { for (let i = 0; i < numLutEntries; i++) { lut[i] = byteArray[i]; } - } return lut; }; @@ -53,20 +52,33 @@ function _getPaletteColor(paletteColorLookupTableData, lutDescriptor) { if (paletteColorLookupTableData.InlineBinary) { try { - const arraybuffer = Uint8Array.from(atob(paletteColorLookupTableData.InlineBinary), c => - c.charCodeAt(0) + const arraybuffer = Uint8Array.from( + atob(paletteColorLookupTableData.InlineBinary), + c => c.charCodeAt(0) ); - return (paletteColorLookupTableData.palette = arrayBufferToPaletteColorLUT(arraybuffer)); + return (paletteColorLookupTableData.palette = arrayBufferToPaletteColorLUT( + arraybuffer + )); } catch (e) { - console.log("Couldn't decode", paletteColorLookupTableData.InlineBinary, e); + console.log( + "Couldn't decode", + paletteColorLookupTableData.InlineBinary, + e + ); return undefined; } } if (paletteColorLookupTableData.retrieveBulkData) { - return paletteColorLookupTableData.retrieveBulkData().then(val => - (paletteColorLookupTableData.palette = arrayBufferToPaletteColorLUT(val))); + return paletteColorLookupTableData + .retrieveBulkData() + .then( + val => + (paletteColorLookupTableData.palette = arrayBufferToPaletteColorLUT( + val + )) + ); } - throw new Error(`No data found for ${paletteColorLookupTableData} palette`) + console.error(`No data found for ${paletteColorLookupTableData} palette`); } diff --git a/platform/docs/docs/deployment/docker.md b/platform/docs/docs/deployment/docker.md new file mode 100644 index 000000000..986a5a5e4 --- /dev/null +++ b/platform/docs/docs/deployment/docker.md @@ -0,0 +1,48 @@ +--- +sidebar_position: 4 +--- + +# Docker + +The OHIF source code provides a Dockerfile to create and run a Docker image that containerizes an [nginx](https://www.nginx.com/) web server serving the OHIF Viewer. + +:::info Good to Know +The OHIF Viewer Docker image for the `v3-stable` branch is not yet published. The available image in [Docker Hub](https://hub.docker.com/r/ohif/viewer) is based on the `master` branch. +::: + +## Prequisites +The machine on which to build and run the Docker container must have: +1. All of the [requirements](./build-for-production.md#build-for-production) for building a production version of OHIF. +2. A checked out branch of the OHIF Viewer. +3. [Docker](https://docs.docker.com/get-docker/) installed. + +## Building the Docker Image +The docker image can be built from a terminal window as such: +1. Switch to the OHIF Viewer code root directory. +2. Issue the following docker command. Note that what follows `-t` flag is the `{name}:{tag}` for the Docker image and is arbitrary when creating a local Docker image. + + ```sh + docker build . -t ohif-viewer-image + ``` + +## Running the Docker Container +Once the Docker image has been built, it can be run as a container from the command line as in the block below. Note that the last argument to the command is the name of the Docker image and the table below describes the other arguments. + +|Flag|Description| +|----|-----------| +|-d|Run the container in the background and print the container ID| +|-p {host-port}:{nginx-port}/tcp|Publish the `nginx` listen port on the given host port| +|--name|An arbitrary name for the container.| + + +```sh +docker run -d -p 3000:80/tcp --name ohif-viewer-container ohif-viewer-image +``` + +### Configuring the `nginx` Listen Port + +The Dockerfile and entry point use the `${PORT}` environment variable as the port that the `nginx` server uses to serve the web server. The default value for `${PORT}` is `80`. One way to set this environment variable is to use the `-e` switch when running the container with `docker run`. The block below gives an example where the listen port is set to `8080` and publised on the host as `3000`. + +```sh +docker run -d -e PORT=8080 -p 3000:8080/tcp --name ohif-viewer-container ohif-viewer-image +``` diff --git a/platform/docs/docs/deployment/google-cloud-healthcare.md b/platform/docs/docs/deployment/google-cloud-healthcare.md index 532bf4cc6..0484bbdfb 100644 --- a/platform/docs/docs/deployment/google-cloud-healthcare.md +++ b/platform/docs/docs/deployment/google-cloud-healthcare.md @@ -1,5 +1,5 @@ --- -sidebar_position: 6 +sidebar_position: 7 --- # Google Cloud Healthcare diff --git a/platform/docs/docs/deployment/nginx--image-archive.md b/platform/docs/docs/deployment/nginx--image-archive.md index 79f729d2f..29ce7a914 100644 --- a/platform/docs/docs/deployment/nginx--image-archive.md +++ b/platform/docs/docs/deployment/nginx--image-archive.md @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 5 --- # Nginx + Image Archive diff --git a/platform/docs/docs/deployment/user-account-control.md b/platform/docs/docs/deployment/user-account-control.md index e411cc7d5..3f8a50a77 100644 --- a/platform/docs/docs/deployment/user-account-control.md +++ b/platform/docs/docs/deployment/user-account-control.md @@ -1,5 +1,5 @@ --- -sidebar_position: 5 +sidebar_position: 6 --- # User Account Control diff --git a/platform/docs/docs/platform/extensions/index.md b/platform/docs/docs/platform/extensions/index.md index 97a7e7647..d4637fde1 100644 --- a/platform/docs/docs/platform/extensions/index.md +++ b/platform/docs/docs/platform/extensions/index.md @@ -206,8 +206,10 @@ used to initialize data. [`onModeExit`](./lifecycle#onModeExit): Similarly to onModeEnter, this hook is called when navigating away from a mode, or before a mode’s data or datasource -is changed. This can be used to clean up data (e.g. remove annotations that do -not need to be persisted) +is changed. This can be used to cache data for re-use later, but since it +isn't known which mode will be entered next, the state after exiting should be +clean, that is, the same as the state on a clean start. This is called BEFORE +service clean up, and after mode specific onModeExit handling. ## Modules diff --git a/platform/docs/docs/platform/managers/service.md b/platform/docs/docs/platform/managers/service.md index 2cacda412..8307d1c68 100644 --- a/platform/docs/docs/platform/managers/service.md +++ b/platform/docs/docs/platform/managers/service.md @@ -51,6 +51,7 @@ By default, `OHIF-v3` registers the following services in the `appInit`. ```js title="platform/viewer/src/appInit.js" servicesManager.registerServices([ + CustomizationService, UINotificationService, UIModalService, UIDialogService, @@ -86,9 +87,16 @@ export default { and the implementation of `ToolBarService` lies in the same folder at `./ToolbarSerivce.js`. -> Note, the create method is critical for any custom service that you write and +> Note: The create method is critical for any custom service that you write and > want to add to the list of services +> Note: For typescript definitions, the service type should be exported +> as part of the Types export on the module. This is recommended going forward +> and existing services will be migrated. As well, the capitalization of the +> name should be lower camel case, with the type being upper camel case. In +> the above example, the service instance should be `toolBarService` with the +> class being `ToolBarService`. + ## Accessing Services Throughout the app you can use `services` property of the service manager to @@ -135,13 +143,15 @@ export default { and the logic for your service shall be ```js title="extensions/customExtension/src/services/backEndService/index.js" -import backEndService from './backEndService'; +// Canonical name of upper camel case BackEndService for the class +import BackEndService from './BackEndService'; export default function WrappedBackEndService(serviceManager) { return { - name: 'myService', + // Note the canonical name of lower camel case backEndService + name: 'backEndService', create: ({ configuration = {} }) => { - return new backEndService(serviceManager); + return new BackEndService(serviceManager); }, }; } @@ -149,8 +159,8 @@ export default function WrappedBackEndService(serviceManager) { with implementation of -```js -export default class backEndService { +```ts +export default class BackEndService { constructor(serviceManager) { this.serviceManager = serviceManager; } @@ -160,3 +170,35 @@ export default class backEndService { } } ``` + +with a registration of + +```ts title="types/index.ts" +import BackEndService from "../services/BackEndService/BackEndService"; + +export { BackEndService }; +``` + +# Service Mode Lifecycle +Services may implement initialization and cleanup for mode specific data. +In order to prevent defects where there are differences between initial +and subsequent displays of a study, the contract of the service is that the +state the service is in on mode entry shall be the same whether the mode was +entered or was exited and entered again. + +To implement storage/recovery of state, the mode must store the data on +exiting the mode, and restore the data in it's onModeEnter. For example, +the mode may decide to preserve measurement data in the onModeExit, and +to restore it in the onModeEnter. This does not violate the contract since +it is the mode's decision to apply the stored state, and to cache it. + +## onModeEnter +A service may implement an onModeEnter call to initialize the service to +be ready for entering a mode. +This is called before the mode `onModeEnter` is called. + +## onModeExit +When entering a mode, the service contract states that the service needs to +be in the same state whether it is a fresh load or has previously entered the mode. +The onModeExit allows a service to clean itself up after the mode 'onModeExit' +has stored any persistent data. diff --git a/platform/docs/docs/platform/modes/lifecycle.md b/platform/docs/docs/platform/modes/lifecycle.md index 4993c30f5..92edeed5d 100644 --- a/platform/docs/docs/platform/modes/lifecycle.md +++ b/platform/docs/docs/platform/modes/lifecycle.md @@ -16,7 +16,11 @@ Currently, there are two hooks that are called for modes: This hook gets run after the defined route has been entered by the mode. This hook can be used to initialize the data, services and appearance of the viewer -upon the first render. +upon the first render, in any way that is custom to the mode. + +This is called after service `onModeEnter` calls so that the entry into a mode +is done in a predefined/fixed state. That allows any restoring of existing state +to be performed. For instance, in `longitudinal` mode we are using this hook to initialize the `ToolBarService` and set the window level/width tool to be active and add @@ -64,9 +68,13 @@ function modeFactory() { ## onModeExit -This hook is called when the viewer navigate away from the route in the url. -This is the place for cleaning up data, and services by unsubscribing to the -events. +This hook is called when the viewer navigates away from the route in the url. +It is called BEFORE the service specific onModeExit calls are performed, and +thus still has access to stateful data which can be cached or stored before +the services clean themselves up. +This is the place for cleaning up NON-service specific data, and services +by unsubscribing to the events. The cleanup of the service itself is intended +to occur in the service `onModeEnter`. For instance, it can be used to reset the `ToolBarService` which reset the toggled buttons. diff --git a/platform/docs/docs/platform/services/data/index.md b/platform/docs/docs/platform/services/data/index.md index fa9128ae4..ae1589276 100644 --- a/platform/docs/docs/platform/services/data/index.md +++ b/platform/docs/docs/platform/services/data/index.md @@ -19,6 +19,7 @@ We maintain the following non-ui Services: - [Hanging Protocol Service](../data/HangingProtocolService.md) - [Toolbar Service](../data/ToolBarService.md) - [Measurement Service](../data/MeasurementService.md) +- [Customization Service](customization-service.md) ## Service Architecture diff --git a/platform/docs/docs/platform/services/index.md b/platform/docs/docs/platform/services/index.md index 6fb2f8295..c2190e9a9 100644 --- a/platform/docs/docs/platform/services/index.md +++ b/platform/docs/docs/platform/services/index.md @@ -125,6 +125,17 @@ The following services is available in the `OHIF-v3`. cine + + + + CustomizationService + + + UI Service + + customizationService (NEW) + + diff --git a/platform/docs/docs/platform/services/ui/customization-service.md b/platform/docs/docs/platform/services/ui/customization-service.md new file mode 100644 index 000000000..7e83fad8f --- /dev/null +++ b/platform/docs/docs/platform/services/ui/customization-service.md @@ -0,0 +1,324 @@ +--- +sidebar_position: 7 +sidebar_label: Customization Service +--- +# Customization Service + +There are a lot of places where users may want to configure certain elements +differently between different modes or for different deployments. A mode +example might be the use of a custom overlay showing mode related DICOM header +information such as radiation dose or patient age. + +The use of this service enables these to be defined in a typed fashion by +providing an easy way to set default values for this, but to allow a +non-default value to be specified by the configuration or mode. + +This service is a UI service in that part of the registration allows for registering +UI components and types to deal with, but it does not directly provide an UI +displayable elements unless customized to do so. + +## Registering Customizations +There are several ways to register customizations. The +`APP_CONFIG.customizationService` +field is used as a per-configuration entry. This object can list single +configurations by id, or it can list sets of customizations by referring to +the `customizationModule` in an extension. For example, the fictitious +customization 'customIcons' might be defined as below in the APP_CONFIG: + +```js +window.config = { + ..., + customizationService: [ + { + id: 'customIcons', + backArrow: 'https://customIcons.org/backArrow.svg', + }, + ], + ... +} +``` + +As well, extensions can register default customizations by providing a 'default' +name key within the extension. These are simply customizations loaded when +the extension is loaded. For example, the previous customization could have +been added in an extension as: + +```js + getCustomizationModule: () => [ + { + name: 'default', + value: { + id: 'customIcons', + backArrow: 'https://customIcons.org/backArrow.svg', + }, + }, + ], +``` + +Note the name of this is default (thus loaded automatically instead of by +reference), and the value is a customization of customIcons. + +The type and parameters of a customization are defined by the user of the +customization, based on the customization id. For example, `cornerstoneOverlay` +is a customization that is a React component, so it requires a react content, +and optionally contentProps which are used to supply values to the content. + +The extension can also supply a default parent instance to inherit values from. +This allows the content or other parameters to be pre-filled, and only the +required values changed. The parent to use is specified by the `customizationType` field, +and is simply the id of another customization object. An example of this might +be a demographics overlay field, where the base version needs an actual component, +while the typed version just needs the attribute and label to use. + +```js + getCustomizationModule: () => [ + { + name: 'default', + value: [ + // This first value defines the base type + { + id: imageDemographicOverlay, + content: function({image}) { + return (

{image[this.attribute]}

); + } + }, + // The second one defines an instance. + // It may or may not use the previous type definition - it will use it + // if nothing replaces the previous definition, otherwise it will use the new one. + { + id: PatientIDOverlayItem, + customizationType: 'imageDemographicOverlay', + attribute: 'PatientID', + }, + ] + } + ] +``` + +Mode-specific customizations are no different from the global ones, +except that the mode customizations are cleared before the mode `onModeEnter` +is called, and they can have new values registered in the `onModeEnter` + +The following example shows first the registration of the default instances, +and then shows how they might be used. + +```js +// In the cornerstone extension getCustomizationModule: +const getCustomizationModule = () => ([ + { + name: 'default', + value: [ + { + id: 'ohif.cornerstoneOverlay', + content: CornerstoneOverlay, + // Requires items on instances + }, + { + id: 'ohif.overlayItem', + content: CornerstoneOverlayItem, + // Requires attribute and label on instances + }, + ], + }, +]); +``` + +Then, in the configuration file one might have a custom overlay definition: + +```js +// in the APP_CONFIG file set the top right area to show the patient name +// using PN: as a prefix when the study has a non-empty patient name. +customizationService: { + cornerstoneOverlayTopRight: { + id: 'cornerstoneOverlayTopRight', + customizationType: 'ohif.cornerstoneOverlay', + items: [ + { + id: 'PatientNameOverlay', + // Note the ohif.overlayItem is a prototype instance for this object + // The ohif.overlayItem is defined up above + customizationType: 'ohif.overlayItem', + attribute: 'PatientName', + label: 'PN:', + }, + ], + }, +}, +``` + +In the mode customization, the overlay is then further customized +with a bottom-right overlay, which extends the customizationService configuration. + +```js +// Import the type from the extension itself +import OverlayUICustomization from '@ohif/cornerstone-extension'; + + +// In the mode itself, customizations can be registered: +onModeEnter() { + ... + // Note how the object can be strongly typed + const bottomRight: OverlayUICustomization = { + id: 'cornerstoneOverlayBottomRight', + // Note the type is the previously registered ohif.cornerstoneOverlay + customizationType: 'ohif.cornerstoneOverlay', + // The cornerstoneOverlay definition requires an items list here. + items: [ + // Custom definitions for hte context menu here. + ], + }; + customizationService.addModeCustomizations(bottomRight); +``` + +## Mode Customizations +The mode customizations are retrieved via the `getModeCustomization` function, +providing an id, and optionally a default value. The retrieval will return, +in order: + +1. Global customization with the given id. +2. Mode customization with the id. +3. The default value specified. + +The return value then inherits the `customizationType` instance, so that the +value can be typed and have default values and functionality provided. The object +can then be used in a way defined by the extension provided that customization +point. + +```ts + cornerstoneOverlay = uiConfigurationService.getModeCustomization("cornerstoneOverlay", {customizationType: "ohif.cornerstoneOverlay", ...}); + const { component: overlayComponent, props} = uiConfigurationService.getComponent(cornerstoneOverlay); + return (); +``` + +This example shows fetching the default component to render this object. The +returned object would be a sub-type of ohif.cornerstoneOverlay if defined. This +object can be a React component or other object such as a commands list, for +example (this example comes from the context menu customizations as that one +uses commands lists): + +```ts + cornerstoneContextMenu = uiConfigurationService.getModeCustomization("cornerstoneContextMenu", defaultMenu); + uiConfigurationService.recordInteraction(cornerstoneContextMenu, extraProps); +``` + +## Global Customizations +Global customizations are retrieved in the same was as mode customizations, except +that the `getGlobalCustomization` is called instead of the mode call. + +## Types +Some types for the customization service are provided by the `@ohif/ui` types +export. Additionally, extensions can provide a Types export with custom +typing, allowing for better typing for the extension specific capabilities. +This allows for having strong typing when declaring customizations, for example: + +```ts +import { Types } from '@ohif/ui'; + +const customContextMenu: Types.UIContextMenu = + { + id: 'cornerstoneContextMenu', + customizationType: 'ohif.contextMenu', + // items will be type checked to be in accordance with UIContextMenu.items + items: [ ... ] + }, +``` + +## Inheritance +JavaScript property inheritance can be supplied by defining customizations +with id corresponding to the customizationType value. For example: + +```js +getCustomizationModule = () => ([ + { + name: 'default', + value: [ + { + id: 'ohif.overlayItem', + content: function (props) { + return (

{this.label} {props.instance[this.attribute]}

) + }, + }, + ], + } +]) +``` + +defines an overlay item which has a React content object as the render value. +This can then be used by specifying a customizationType of `ohif.overlayItem`, for example: + +```js +const overlayItem: Types.UIOverlayItem = { + id: 'anOverlayItem', + customizationType: 'ohif.overlayItem', + attribute: 'PatientName', + label: 'PN:', +}; +``` + +# Customizations +This section can be used to specify various customization capabilities. + + +## Text color for StudyBrowser tabs +This is the recommended pattern for deep customization of class attributes, +making it fine grained, and have it apply a set of attributes, mostly from +tailwind. In this case it is a double indirection, as the buttons class +uses it's own internal class names. + +* Name: 'class:StudyBrowser' +* Attributes: +** `true` for the is active true text color +** `false` fo rhte is active false text color. +** Values are button colors, from the Button class, eg default, white, black + +## customRoutes + +* Name: `customRoutes` global +* Attributes: +** `routes` of type List of route objects (see `route/index.tsx`) is a set of route objects to add. +** Should any element of routes match an existing baked in element, the baked in one will be replaced. +** `notFoundRoute` is the route to display when nothing is found (this has to be at the end of the overall list, so can't be added to routes) + +### Example + +```js +{ + id: 'customRoutes', + routes: [ + { + path: '/myroute', + children: MyRouteReactFunction, + } + ], +} +``` + +There is a usage of this example commented out in config/default.js that +looks like the code below. This example is provided by the default extension, +again with commented out code. Uncomment the getCustomizationModule customRoutes +code in the default module to activate this, and then go to: `http://localhost:3000/custom` +to see the custom route. + +Note the name of this is the customization module name, which usually won't match +the id, and in fact there can be multiple customization objects defined for a single +customization module, to allow for customizing sets of related values. + +```js +customizationService: [ + // Shows a custom route -access via http://localhost:3000/custom + '@ohif/extension-default.customizationModule.helloPage', +], +``` + +> 3rd Party implementers may be added to this table via pull requests. + + + + +[interface]: https://github.com/OHIF/Viewers/blob/master/platform/core/src/services/UIModalService/index.js +[modal-provider]: https://github.com/OHIF/Viewers/blob/master/platform/ui/src/contextProviders/ModalProvider.js +[modal-consumer]: https://github.com/OHIF/Viewers/tree/master/platform/ui/src/components/ohifModal +[ux-article]: https://uxplanet.org/best-practices-for-modals-overlays-dialog-windows-c00c66cddd8c + diff --git a/platform/ui/package.json b/platform/ui/package.json index c60994b49..9c5ff526f 100644 --- a/platform/ui/package.json +++ b/platform/ui/package.json @@ -44,7 +44,6 @@ "react-outside-click-handler": "^1.3.0", "react-select": "3.0.8", "react-with-direction": "^1.3.1", - "lottie-react": "^2.3.1", "swiper": "^8.4.2" }, "devDependencies": { diff --git a/platform/ui/src/components/Button/Button.tsx b/platform/ui/src/components/Button/Button.tsx index 03c4ea1de..104db9368 100644 --- a/platform/ui/src/components/Button/Button.tsx +++ b/platform/ui/src/components/Button/Button.tsx @@ -44,9 +44,12 @@ const variants = { 'text-primary-main hover:opacity-80 active:opacity-100 focus:opacity-80', secondary: 'text-secondary-light hover:opacity-80 active:opacity-100 focus:opacity-80', - white: 'text-white hover:opacity-80 active:opacity-100 focus:opacity-80', - black: + translucent: + 'text-white hover:opacity-80 active:opacity-100 focus:opacity-80', + white: 'text-black hover:bg-primary-main focus:bg-primary-main hover:border-black focus:border-black', + black: + 'text-white hover:bg-primary-main focus:bg-primary-main hover:border-black focus:border-black', primaryActive: 'text-primary-active hover:opacity-80 active:opacity-100 focus:opacity-80', primaryLight: @@ -227,11 +230,13 @@ Button.propTypes = { color: PropTypes.oneOf([ 'default', 'primary', + 'primaryActive', 'secondary', 'white', 'black', 'inherit', 'light', + 'translucent', ]), border: PropTypes.oneOf([ 'none', diff --git a/platform/ui/src/components/LoadingIndicator/LoadingIndicator.tsx b/platform/ui/src/components/LoadingIndicator/LoadingIndicator.tsx deleted file mode 100644 index 0d1b99e1c..000000000 --- a/platform/ui/src/components/LoadingIndicator/LoadingIndicator.tsx +++ /dev/null @@ -1,793 +0,0 @@ -import React from 'react'; -import Lottie from 'lottie-react'; -import classNames from 'classnames'; - -const LoadingAnimation = { - v: '5.9.6', - fr: 60, - ip: 0, - op: 106, - w: 215, - h: 215, - nm: 'ohif-progress', - ddd: 0, - assets: [], - layers: [ - { - ddd: 0, - ind: 1, - ty: 4, - nm: 'Layer 1/ohif-shape-new Outlines', - sr: 1, - ks: { - o: { a: 0, k: 100, ix: 11 }, - r: { - a: 1, - k: [ - { - i: { x: [0.833], y: [0.797] }, - o: { x: [0.274], y: [0.135] }, - t: 25, - s: [0], - }, - { - i: { x: [0.828], y: [0.97] }, - o: { x: [0.446], y: [0.039] }, - t: 52, - s: [90], - }, - { - i: { x: [0.83], y: [1.207] }, - o: { x: [0.167], y: [-0.246] }, - t: 65, - s: [90], - }, - { t: 85, s: [180] }, - ], - ix: 10, - }, - p: { - a: 1, - k: [ - { - i: { x: 0.667, y: 0.667 }, - o: { x: 0.167, y: 0.167 }, - t: 0, - s: [130.5, 131.75, 0], - to: [0, 0, 0], - ti: [0, 0, 0], - }, - { - i: { x: 0.833, y: 0.806 }, - o: { x: 0.167, y: 0.006 }, - t: 9, - s: [130.5, 131.75, 0], - to: [2.917, 2.875, 0], - ti: [-2.917, -2.875, 0], - }, - { - i: { x: 0.872, y: 0.872 }, - o: { x: 0.167, y: 0.167 }, - t: 40, - s: [148, 149, 0], - to: [0, 0, 0], - ti: [0, 0, 0], - }, - { - i: { x: 0.856, y: 0.811 }, - o: { x: 0.167, y: 0.17 }, - t: 52, - s: [148, 149, 0], - to: [-0.881, -0.866, 0], - ti: [1.995, 1.961, 0], - }, - { - i: { x: 0.833, y: 0.833 }, - o: { x: 0.167, y: 0 }, - t: 76, - s: [108.4, 113.023, 0], - to: [-1.196, -1.176, 0], - ti: [-2.169, -1.843, 0], - }, - { - i: { x: 0.833, y: 0.832 }, - o: { x: 0.167, y: 0.167 }, - t: 85, - s: [108.4, 113, 0], - to: [1.272, 1.081, 0], - ti: [-1.819, -1.575, 0], - }, - { - i: { x: 0.833, y: 0.941 }, - o: { x: 0.167, y: 0.008 }, - t: 95, - s: [115.214, 118.607, 0], - to: [7.038, 6.091, 0], - ti: [1.198, 1.177, 0], - }, - { t: 106, s: [130.5, 131.8, 0] }, - ], - ix: 2, - l: 2, - }, - a: { a: 0, k: [21, 21, 0], ix: 1, l: 2 }, - s: { a: 0, k: [100, 100, 100], ix: 6, l: 2 }, - }, - ao: 0, - shapes: [ - { - ty: 'gr', - it: [ - { - ind: 0, - ty: 'sh', - ix: 1, - ks: { - a: 0, - k: { - i: [ - [-1.657, 0], - [0, 0], - [0, -1.657], - [0, 0], - [1.657, 0], - [0, 0], - [0, 1.657], - [0, 0], - ], - o: [ - [0, 0], - [1.657, 0], - [0, 0], - [0, 1.657], - [0, 0], - [-1.657, 0], - [0, 0], - [0, -1.657], - ], - v: [ - [-13, -16], - [13, -16], - [16, -13], - [16, 13], - [13, 16], - [-13, 16], - [-16, 13], - [-16, -13], - ], - c: true, - }, - ix: 2, - }, - nm: 'Path 1', - mn: 'ADBE Vector Shape - Group', - hd: false, - }, - { - ty: 'st', - c: { a: 0, k: [1, 1, 1, 1], ix: 3 }, - o: { a: 0, k: 100, ix: 4 }, - w: { a: 0, k: 2, ix: 5 }, - lc: 1, - lj: 1, - ml: 10, - bm: 0, - nm: 'Stroke 1', - mn: 'ADBE Vector Graphic - Stroke', - hd: false, - }, - { - ty: 'tr', - p: { a: 0, k: [21, 21], ix: 2 }, - a: { a: 0, k: [0, 0], ix: 1 }, - s: { a: 0, k: [100, 100], ix: 3 }, - r: { a: 0, k: 0, ix: 6 }, - o: { a: 0, k: 100, ix: 7 }, - sk: { a: 0, k: 0, ix: 4 }, - sa: { a: 0, k: 0, ix: 5 }, - nm: 'Transform', - }, - ], - nm: 'Group 1', - np: 2, - cix: 2, - bm: 0, - ix: 1, - mn: 'ADBE Vector Group', - hd: false, - }, - ], - ip: 0, - op: 106, - st: 0, - ct: 1, - bm: 0, - }, - { - ddd: 0, - ind: 3, - ty: 4, - nm: 'Layer 1/ohif-shape-new Outlines', - sr: 1, - ks: { - o: { a: 0, k: 100, ix: 11 }, - r: { - a: 1, - k: [ - { - i: { x: [0.833], y: [0.79] }, - o: { x: [0.278], y: [0.149] }, - t: 25, - s: [0], - }, - { - i: { x: [0.828], y: [1.005] }, - o: { x: [0.446], y: [0.022] }, - t: 52, - s: [90], - }, - { - i: { x: [0.83], y: [1.207] }, - o: { x: [0.167], y: [-0.246] }, - t: 65, - s: [90], - }, - { t: 85, s: [180] }, - ], - ix: 10, - }, - p: { - a: 1, - k: [ - { - i: { x: 0.833, y: 0.833 }, - o: { x: 0.167, y: 0.167 }, - t: 0, - s: [81, 132, 0], - to: [0, 0, 0], - ti: [0, 0, 0], - }, - { - i: { x: 0.833, y: 0.808 }, - o: { x: 0.167, y: 0.011 }, - t: 9, - s: [81, 132, 0], - to: [-3, 3, 0], - ti: [3, -3, 0], - }, - { - i: { x: 0.872, y: 0.872 }, - o: { x: 0.167, y: 0.167 }, - t: 40, - s: [63, 150, 0], - to: [0, 0, 0], - ti: [0, 0, 0], - }, - { - i: { x: 0.856, y: 0.812 }, - o: { x: 0.167, y: 0.174 }, - t: 52, - s: [63, 150, 0], - to: [0.906, -0.906, 0], - ti: [-2.052, 2.052, 0], - }, - { - i: { x: 0.833, y: 0.833 }, - o: { x: 0.167, y: 0.002 }, - t: 76, - s: [108.685, 113.315, 0], - to: [1.217, -1.217, 0], - ti: [2.674, -1.792, 0], - }, - { - i: { x: 0.833, y: 0.834 }, - o: { x: 0.167, y: 0.167 }, - t: 85, - s: [108.7, 113.3, 0], - to: [-1.593, 1.067, 0], - ti: [2.246, -1.581, 0], - }, - { - i: { x: 0.833, y: 0.942 }, - o: { x: 0.167, y: 0.014 }, - t: 95, - s: [99.965, 118.742, 0], - to: [-8.674, 6.104, 0], - ti: [-1.236, 1.236, 0], - }, - { t: 106, s: [81, 132, 0] }, - ], - ix: 2, - l: 2, - }, - a: { a: 0, k: [21, 21, 0], ix: 1, l: 2 }, - s: { a: 0, k: [100, 100, 100], ix: 6, l: 2 }, - }, - ao: 0, - shapes: [ - { - ty: 'gr', - it: [ - { - ind: 0, - ty: 'sh', - ix: 1, - ks: { - a: 0, - k: { - i: [ - [-1.657, 0], - [0, 0], - [0, -1.657], - [0, 0], - [1.657, 0], - [0, 0], - [0, 1.657], - [0, 0], - ], - o: [ - [0, 0], - [1.657, 0], - [0, 0], - [0, 1.657], - [0, 0], - [-1.657, 0], - [0, 0], - [0, -1.657], - ], - v: [ - [-13, -16], - [13, -16], - [16, -13], - [16, 13], - [13, 16], - [-13, 16], - [-16, 13], - [-16, -13], - ], - c: true, - }, - ix: 2, - }, - nm: 'Path 1', - mn: 'ADBE Vector Shape - Group', - hd: false, - }, - { - ty: 'st', - c: { a: 0, k: [1, 1, 1, 1], ix: 3 }, - o: { a: 0, k: 100, ix: 4 }, - w: { a: 0, k: 2, ix: 5 }, - lc: 1, - lj: 1, - ml: 10, - bm: 0, - nm: 'Stroke 1', - mn: 'ADBE Vector Graphic - Stroke', - hd: false, - }, - { - ty: 'tr', - p: { a: 0, k: [21, 21], ix: 2 }, - a: { a: 0, k: [0, 0], ix: 1 }, - s: { a: 0, k: [100, 100], ix: 3 }, - r: { a: 0, k: 0, ix: 6 }, - o: { a: 0, k: 100, ix: 7 }, - sk: { a: 0, k: 0, ix: 4 }, - sa: { a: 0, k: 0, ix: 5 }, - nm: 'Transform', - }, - ], - nm: 'Group 1', - np: 2, - cix: 2, - bm: 0, - ix: 1, - mn: 'ADBE Vector Group', - hd: false, - }, - ], - ip: 0, - op: 106, - st: 0, - ct: 1, - bm: 0, - }, - { - ddd: 0, - ind: 5, - ty: 4, - nm: 'Layer 1/ohif-shape-new Outlines', - sr: 1, - ks: { - o: { a: 0, k: 100, ix: 11 }, - r: { - a: 1, - k: [ - { - i: { x: [0.833], y: [0.79] }, - o: { x: [0.278], y: [0.149] }, - t: 25, - s: [0], - }, - { - i: { x: [0.828], y: [0.943] }, - o: { x: [0.446], y: [0.022] }, - t: 52, - s: [90], - }, - { - i: { x: [0.83], y: [1.207] }, - o: { x: [0.167], y: [-0.246] }, - t: 65, - s: [90], - }, - { t: 85, s: [180] }, - ], - ix: 10, - }, - p: { - a: 1, - k: [ - { - i: { x: 0.833, y: 0.833 }, - o: { x: 0.167, y: 0.167 }, - t: 0, - s: [131, 84.75, 0], - to: [0, 0, 0], - ti: [0, 0, 0], - }, - { - i: { x: 0.833, y: 0.808 }, - o: { x: 0.167, y: 0.014 }, - t: 9, - s: [131, 84.75, 0], - to: [3, -3.125, 0], - ti: [-3, 3.125, 0], - }, - { - i: { x: 0.872, y: 0.872 }, - o: { x: 0.167, y: 0.167 }, - t: 40, - s: [149, 66, 0], - to: [0, 0, 0], - ti: [0, 0, 0], - }, - { - i: { x: 0.856, y: 0.812 }, - o: { x: 0.167, y: 0.175 }, - t: 52, - s: [149, 66, 0], - to: [-0.906, 0.946, 0], - ti: [2.052, -2.143, 0], - }, - { - i: { x: 0.833, y: 0.833 }, - o: { x: 0.167, y: 0.013 }, - t: 76, - s: [109.315, 112.893, 0], - to: [-1.222, 1.276, 0], - ti: [-2.102, 2.732, 0], - }, - { - i: { x: 0.833, y: 0.835 }, - o: { x: 0.167, y: 0.167 }, - t: 85, - s: [109.3, 112.9, 0], - to: [1.242, -1.615, 0], - ti: [-1.796, 2.277, 0], - }, - { - i: { x: 0.833, y: 0.943 }, - o: { x: 0.167, y: 0.016 }, - t: 95, - s: [115.852, 104.071, 0], - to: [6.96, -8.823, 0], - ti: [1.236, -1.29, 0], - }, - { t: 106, s: [131, 84.8, 0] }, - ], - ix: 2, - l: 2, - }, - a: { a: 0, k: [21, 21, 0], ix: 1, l: 2 }, - s: { a: 0, k: [100, 100, 100], ix: 6, l: 2 }, - }, - ao: 0, - shapes: [ - { - ty: 'gr', - it: [ - { - ind: 0, - ty: 'sh', - ix: 1, - ks: { - a: 0, - k: { - i: [ - [-1.657, 0], - [0, 0], - [0, -1.657], - [0, 0], - [1.657, 0], - [0, 0], - [0, 1.657], - [0, 0], - ], - o: [ - [0, 0], - [1.657, 0], - [0, 0], - [0, 1.657], - [0, 0], - [-1.657, 0], - [0, 0], - [0, -1.657], - ], - v: [ - [-13, -16], - [13, -16], - [16, -13], - [16, 13], - [13, 16], - [-13, 16], - [-16, 13], - [-16, -13], - ], - c: true, - }, - ix: 2, - }, - nm: 'Path 1', - mn: 'ADBE Vector Shape - Group', - hd: false, - }, - { - ty: 'st', - c: { a: 0, k: [1, 1, 1, 1], ix: 3 }, - o: { a: 0, k: 100, ix: 4 }, - w: { a: 0, k: 2, ix: 5 }, - lc: 1, - lj: 1, - ml: 10, - bm: 0, - nm: 'Stroke 1', - mn: 'ADBE Vector Graphic - Stroke', - hd: false, - }, - { - ty: 'tr', - p: { a: 0, k: [21, 21], ix: 2 }, - a: { a: 0, k: [0, 0], ix: 1 }, - s: { a: 0, k: [100, 100], ix: 3 }, - r: { a: 0, k: 0, ix: 6 }, - o: { a: 0, k: 100, ix: 7 }, - sk: { a: 0, k: 0, ix: 4 }, - sa: { a: 0, k: 0, ix: 5 }, - nm: 'Transform', - }, - ], - nm: 'Group 1', - np: 2, - cix: 2, - bm: 0, - ix: 1, - mn: 'ADBE Vector Group', - hd: false, - }, - ], - ip: 0, - op: 106, - st: 0, - ct: 1, - bm: 0, - }, - { - ddd: 0, - ind: 7, - ty: 4, - nm: 'Layer 1/ohif-shape-new Outlines', - sr: 1, - ks: { - o: { a: 0, k: 100, ix: 11 }, - r: { - a: 1, - k: [ - { - i: { x: [0.833], y: [0.79] }, - o: { x: [0.278], y: [0.149] }, - t: 25, - s: [0], - }, - { - i: { x: [0.823], y: [1] }, - o: { x: [0.446], y: [-0.058] }, - t: 52, - s: [90], - }, - { - i: { x: [0.83], y: [1.207] }, - o: { x: [0.167], y: [-0.246] }, - t: 65, - s: [90], - }, - { t: 85, s: [180] }, - ], - ix: 10, - }, - p: { - a: 1, - k: [ - { - i: { x: 0.833, y: 0.833 }, - o: { x: 0.167, y: 0.167 }, - t: 0, - s: [80.5, 85, 0], - to: [0, 0, 0], - ti: [0, 0, 0], - }, - { - i: { x: 0.833, y: 0.806 }, - o: { x: 0.167, y: 0.005 }, - t: 9, - s: [80.5, 85, 0], - to: [-2.75, -3, 0], - ti: [2.75, 3, 0], - }, - { - i: { x: 0.872, y: 0.872 }, - o: { x: 0.167, y: 0.167 }, - t: 40, - s: [64, 67, 0], - to: [0, 0, 0], - ti: [0, 0, 0], - }, - { - i: { x: 0.856, y: 0.812 }, - o: { x: 0.167, y: 0.177 }, - t: 52, - s: [64, 67, 0], - to: [0.831, 0.906, 0], - ti: [-1.881, -2.052, 0], - }, - { - i: { x: 0.833, y: 0.833 }, - o: { x: 0.167, y: 0.025 }, - t: 76, - s: [109.295, 112.685, 0], - to: [1.112, 1.213, 0], - ti: [2.768, 2.657, 0], - }, - { - i: { x: 0.833, y: 0.837 }, - o: { x: 0.167, y: 0.167 }, - t: 85, - s: [109.3, 112.7, 0], - to: [-1.648, -1.581, 0], - ti: [2.299, 2.234, 0], - }, - { - i: { x: 0.833, y: 0.943 }, - o: { x: 0.167, y: 0.019 }, - t: 95, - s: [100.116, 104.028, 0], - to: [-8.935, -8.681, 0], - ti: [-1.136, -1.239, 0], - }, - { t: 106, s: [80.5, 85, 0] }, - ], - ix: 2, - l: 2, - }, - a: { a: 0, k: [21, 21, 0], ix: 1, l: 2 }, - s: { a: 0, k: [100, 100, 100], ix: 6, l: 2 }, - }, - ao: 0, - shapes: [ - { - ty: 'gr', - it: [ - { - ind: 0, - ty: 'sh', - ix: 1, - ks: { - a: 0, - k: { - i: [ - [-1.657, 0], - [0, 0], - [0, -1.657], - [0, 0], - [1.657, 0], - [0, 0], - [0, 1.657], - [0, 0], - ], - o: [ - [0, 0], - [1.657, 0], - [0, 0], - [0, 1.657], - [0, 0], - [-1.657, 0], - [0, 0], - [0, -1.657], - ], - v: [ - [-13, -16], - [13, -16], - [16, -13], - [16, 13], - [13, 16], - [-13, 16], - [-16, 13], - [-16, -13], - ], - c: true, - }, - ix: 2, - }, - nm: 'Path 1', - mn: 'ADBE Vector Shape - Group', - hd: false, - }, - { - ty: 'st', - c: { a: 0, k: [1, 1, 1, 1], ix: 3 }, - o: { a: 0, k: 100, ix: 4 }, - w: { a: 0, k: 2, ix: 5 }, - lc: 1, - lj: 1, - ml: 10, - bm: 0, - nm: 'Stroke 1', - mn: 'ADBE Vector Graphic - Stroke', - hd: false, - }, - { - ty: 'tr', - p: { a: 0, k: [21, 21], ix: 2 }, - a: { a: 0, k: [0, 0], ix: 1 }, - s: { a: 0, k: [100, 100], ix: 3 }, - r: { a: 0, k: 0, ix: 6 }, - o: { a: 0, k: 100, ix: 7 }, - sk: { a: 0, k: 0, ix: 4 }, - sa: { a: 0, k: 0, ix: 5 }, - nm: 'Transform', - }, - ], - nm: 'Group 1', - np: 2, - cix: 2, - bm: 0, - ix: 1, - mn: 'ADBE Vector Group', - hd: false, - }, - ], - ip: 0, - op: 106, - st: 0, - ct: 1, - bm: 0, - }, - ], - markers: [], -}; - -const LoadingIndicator = ({ className }) => { - return ( -
- ; -
- ); -}; - -export default LoadingIndicator; diff --git a/platform/ui/src/components/LoadingIndicator/index.js b/platform/ui/src/components/LoadingIndicator/index.js deleted file mode 100644 index 42f308527..000000000 --- a/platform/ui/src/components/LoadingIndicator/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import LoadingIndicator from './LoadingIndicator'; -export default LoadingIndicator; diff --git a/platform/ui/src/components/StudyBrowser/StudyBrowser.tsx b/platform/ui/src/components/StudyBrowser/StudyBrowser.tsx index 209cd7ac8..37993c58e 100644 --- a/platform/ui/src/components/StudyBrowser/StudyBrowser.tsx +++ b/platform/ui/src/components/StudyBrowser/StudyBrowser.tsx @@ -26,8 +26,10 @@ const StudyBrowser = ({ onDoubleClickThumbnail, onClickUntrack, activeDisplaySetInstanceUIDs, + servicesManager, }) => { const { t } = useTranslation('StudyBrowser'); + const { customizationService } = servicesManager?.services || {}; const getTabContent = () => { const tabData = tabs.find(tab => tab.name === activeTabName); @@ -82,8 +84,13 @@ const StudyBrowser = ({ const isActive = activeTabName === name; const isDisabled = !studies.length; // Apply the contrasting color for brighter button color visibility - // const color = isActive ? 'black' : 'default'; - const color = 'default'; + const classStudyBrowser = customizationService?.getModeCustomization( + 'class:StudyBrowser' + ) || { + true: 'default', + false: 'default', + }; + const color = classStudyBrowser[`${isActive}`]; return (
+ ); +} + +export default Debug; diff --git a/platform/viewer/src/routes/Mode/Mode.tsx b/platform/viewer/src/routes/Mode/Mode.tsx index f7159fc53..347114bd8 100644 --- a/platform/viewer/src/routes/Mode/Mode.tsx +++ b/platform/viewer/src/routes/Mode/Mode.tsx @@ -312,11 +312,22 @@ export default function ModeRoute({ }); return () => { - extensionManager.onModeExit(); - mode?.onModeExit({ servicesManager, extensionManager }); + // The mode.onModeExit must be done first to allow it to store + // information, and must be in a try/catch to ensure subscriptions + // are unsubscribed. + try { + mode?.onModeExit?.({ servicesManager, extensionManager }); + } catch (e) { + console.warn('mode exit failure', e); + } + // The unsubscriptions must occur before the extension onModeExit + // in order to prevent exceptions during cleanup caused by spurious events unsubscriptions.forEach(unsub => { unsub(); }); + // The extension manager must be called after the mode, this is + // expected to cleanup the state to a standard setup. + extensionManager.onModeExit(); }; }, [ mode, diff --git a/platform/viewer/src/routes/NotFound/NotFound.tsx b/platform/viewer/src/routes/NotFound/NotFound.tsx index 723b362ab..7c668d0e0 100644 --- a/platform/viewer/src/routes/NotFound/NotFound.tsx +++ b/platform/viewer/src/routes/NotFound/NotFound.tsx @@ -2,15 +2,20 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; +import { useAppConfig } from '@state'; + const NotFound = ({ message = 'Sorry, this page does not exist.', showGoBackButton = true, }) => { + const [appConfig] = useAppConfig(); + const { showStudyList } = appConfig; + return (

{message}

- {showGoBackButton && ( + {showGoBackButton && showStudyList && (
Go back to the Study List
diff --git a/platform/viewer/src/routes/index.tsx b/platform/viewer/src/routes/index.tsx index 2b1570f67..a40d09d44 100644 --- a/platform/viewer/src/routes/index.tsx +++ b/platform/viewer/src/routes/index.tsx @@ -6,11 +6,11 @@ import { ErrorBoundary } from '@ohif/ui'; import DataSourceWrapper from './DataSourceWrapper'; import WorkList from './WorkList'; import Local from './Local'; +import Debug from './Debug'; import NotFound from './NotFound'; import buildModeRoutes from './buildModeRoutes'; import PrivateRoute from './PrivateRoute'; -// TODO: Make these configurable // TODO: Include "routes" debug route if dev build const bakedInRoutes = [ // WORK LIST @@ -20,14 +20,25 @@ const bakedInRoutes = [ private: true, props: { children: WorkList }, }, + { + path: '/debug', + children: Debug, + }, { path: '/local', children: Local, }, - // NOT FOUND (404) - { component: NotFound }, ]; +// NOT FOUND (404) +const notFoundRoute = { component: NotFound }; +const WorkListRoute = { + path: '/', + children: DataSourceWrapper, + private: true, + props: { children: WorkList }, +}; + const createRoutes = ({ modes, dataSources, @@ -36,6 +47,7 @@ const createRoutes = ({ commandsManager, hotkeysManager, routerBasename, + showStudyList, }) => { const routes = buildModeRoutes({ @@ -47,7 +59,18 @@ const createRoutes = ({ hotkeysManager, }) || []; - const allRoutes = [...routes, ...bakedInRoutes]; + const { customizationService } = servicesManager.services; + + const customRoutes = customizationService.getGlobalCustomization( + 'customRoutes' + ); + const allRoutes = [ + ...routes, + ...(showStudyList ? [WorkListRoute] : []), + ...(customRoutes?.routes || []), + ...bakedInRoutes, + customRoutes?.notFoundRoute || notFoundRoute, + ]; function RouteWithErrorBoundary({ route, ...rest }) { // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/yarn.lock b/yarn.lock index cf3a55506..1bc066da8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2383,28 +2383,28 @@ resolved "https://registry.yarnpkg.com/@cornerstonejs/codec-openjpeg/-/codec-openjpeg-0.1.1.tgz#5bd1c52a33a425299299e970312731fa0cc2711b" integrity sha512-HOMMOLV6xy8O/agNGGvrl0a8DwShpBvWxAzEzv2pqq12d3r5z/3MyIgNA3Oj/8bIBVvvVXxh9RX7rMDRHJdowg== -"@cornerstonejs/core@^0.21.4": - version "0.21.4" - resolved "https://registry.npmjs.org/@cornerstonejs/core/-/core-0.21.4.tgz#d1dab2c985c382495a8ec91a28f2d62872cdb350" - integrity sha512-8D8QyK5bjqxefSVr3vLzsp2MUprmABAbX4qpvpp1XRmC76i1dKKR8Yo9G9jdreR5xwKvJN7pY6/n1F2ZYSN48w== +"@cornerstonejs/core@^0.22.3": + version "0.22.3" + resolved "https://registry.npmjs.org/@cornerstonejs/core/-/core-0.22.3.tgz#57bf1482ce91d094342bf116636dea1335bff0b3" + integrity sha512-WqV6TnsTpsgezSj9SmnSgGCdY3u/EwYr6nKD8KnRQI8lqRTmMIKzbcIvPWAKJ/sU+A9Da6A5tweZvrhrcSorGg== dependencies: detect-gpu "^4.0.45" lodash.clonedeep "4.5.0" -"@cornerstonejs/streaming-image-volume-loader@^0.6.5": - version "0.6.5" - resolved "https://registry.npmjs.org/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-0.6.5.tgz#f3f2ab362bf494e4072a146b45acedce0197fb36" - integrity sha512-dowoZYz0tMVtuUQl2WIZjOC/2TC0zMgas8KtpVcxJcGEu9HWA9oatgPlY0WqtFniDU7Oxnyy7OqYawtEoSSwdw== +"@cornerstonejs/streaming-image-volume-loader@^0.6.11": + version "0.6.11" + resolved "https://registry.npmjs.org/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-0.6.11.tgz#53800b12d588300320aa479eef24f168a38826bd" + integrity sha512-UrgiWIsTSPFWEgCKxsKZUZ5hgir8ymdXes2UB8OT7yX6M4QeTmKi26nVNP21vebKMMPBootK//geSToqBoBs4Q== dependencies: - "@cornerstonejs/core" "^0.21.4" + "@cornerstonejs/core" "^0.22.3" cornerstone-wado-image-loader "^4.2.1" -"@cornerstonejs/tools@^0.29.6": - version "0.29.6" - resolved "https://registry.npmjs.org/@cornerstonejs/tools/-/tools-0.29.6.tgz#6cfcb6b56284c4d1265330993ecafa3f48c429b8" - integrity sha512-Rmz4Nx6IfH+31C7fsGxLWwQXQ+w1nN5WmILb9/N7WB4M5mYUZvYiopXdWII4mzh91nHXcEfEiJimaihyuCVLJQ== +"@cornerstonejs/tools@^0.30.6": + version "0.30.6" + resolved "https://registry.npmjs.org/@cornerstonejs/tools/-/tools-0.30.6.tgz#a6e5209f00fe00a919f20fe1387f06062b289a16" + integrity sha512-GEm2xWOlBwgS/3aZDBZObyu+KJESyOmkCo3hA+tellLKCVr207wBUUDndEmkNX6PJ/mR4o9Md6ZjMfNF7CAYiA== dependencies: - "@cornerstonejs/core" "^0.21.4" + "@cornerstonejs/core" "^0.22.3" lodash.clonedeep "4.5.0" lodash.get "^4.4.2" @@ -15797,18 +15797,6 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3 dependencies: js-tokens "^3.0.0 || ^4.0.0" -lottie-react@^2.3.1: - version "2.3.1" - resolved "https://registry.npmjs.org/lottie-react/-/lottie-react-2.3.1.tgz#697060417bab027edb57ed45bda9d62e920d4040" - integrity sha512-8cxd6XZZtECT6LoAhCftRdYrEpHxiouvB5EPiYA+TtCG5LHNYAdMS9IVIHcxKtWnpo7x16QfCLj1XLXZpaN81A== - dependencies: - lottie-web "^5.9.4" - -lottie-web@^5.9.4: - version "5.9.6" - resolved "https://registry.npmjs.org/lottie-web/-/lottie-web-5.9.6.tgz#62ae68563355d3e04aa75d53dec3dd4bea0e57c9" - integrity sha512-JFs7KsHwflugH5qIXBpB4905yC1Sub2MZWtl/elvO/QC6qj1ApqbUZJyjzJseJUtVpgiDaXQLjBlIJGS7UUUXA== - loud-rejection@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f"