diff --git a/extensions/cornerstone-dicom-seg/src/index.tsx b/extensions/cornerstone-dicom-seg/src/index.tsx index da3d909bb..e4ea1944c 100644 --- a/extensions/cornerstone-dicom-seg/src/index.tsx +++ b/extensions/cornerstone-dicom-seg/src/index.tsx @@ -1,6 +1,8 @@ import { id } from './id'; import React from 'react'; +import { Types } from '@ohif/core'; + import getSopClassHandlerModule from './getSopClassHandlerModule'; import PanelSegmentation from './panels/PanelSegmentation'; @@ -39,13 +41,13 @@ const extension = { commandsManager, configuration = {}, }) => {}, - /** + /** * PanelModule should provide a list of panels that will be available in OHIF * for Modes to consume and render. Each panel is defined by a {name, * iconName, iconLabel, label, component} object. Example of a panel module * is the StudyBrowserPanel that is provided by the default extension in OHIF. */ - getPanelModule: ({ servicesManager, commandsManager, extensionManager }) => { + getPanelModule: ({ servicesManager, commandsManager, extensionManager }): Types.Panel[] => { const wrappedPanelSegmentation = () => { return ( ) : null} @@ -248,6 +250,7 @@ function ViewerLayout({ side="right" activeTabIndex={rightPanelDefaultClosed ? null : 0} tabs={rightPanelComponents} + servicesManager={servicesManager} /> ) : null} diff --git a/extensions/measurement-tracking/src/getPanelModule.tsx b/extensions/measurement-tracking/src/getPanelModule.tsx index 6c0eb2280..f9f456944 100644 --- a/extensions/measurement-tracking/src/getPanelModule.tsx +++ b/extensions/measurement-tracking/src/getPanelModule.tsx @@ -1,3 +1,4 @@ +import { Types } from '@ohif/core'; import { PanelMeasurementTableTracking, PanelStudyBrowserTracking, @@ -11,7 +12,7 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager, -}) { +}): Types.Panel[] { return [ { name: 'seriesList', @@ -24,6 +25,7 @@ function getPanelModule({ servicesManager, }), }, + { name: 'trackedMeasurements', iconName: 'tab-linear', diff --git a/modes/longitudinal/src/index.js b/modes/longitudinal/src/index.js index 09c6ec146..ab6887420 100644 --- a/modes/longitudinal/src/index.js +++ b/modes/longitudinal/src/index.js @@ -57,6 +57,7 @@ const extensionDependencies = { }; function modeFactory() { + let _activatePanelTriggersSubscriptions = []; return { // TODO: We're using this as a route segment // We should not be. @@ -71,6 +72,8 @@ function modeFactory() { measurementService, toolbarService, toolGroupService, + panelService, + segmentationService, } = servicesManager.services; measurementService.clearMeasurements(); @@ -121,6 +124,28 @@ function modeFactory() { 'Crosshairs', 'MoreTools', ]); + + // // ActivatePanel event trigger for when a segmentation or measurement is added. + // // Do not force activation so as to respect the state the user may have left the UI in. + // _activatePanelTriggersSubscriptions = [ + // ...panelService.addActivatePanelTriggers(dicomSeg.panel, [ + // { + // sourcePubSubService: segmentationService, + // sourceEvents: [ + // segmentationService.EVENTS.SEGMENTATION_PIXEL_DATA_CREATED, + // ], + // }, + // ]), + // ...panelService.addActivatePanelTriggers(tracked.measurements, [ + // { + // sourcePubSubService: measurementService, + // sourceEvents: [ + // measurementService.EVENTS.MEASUREMENT_ADDED, + // measurementService.EVENTS.RAW_MEASUREMENT_ADDED, + // ], + // }, + // ]), + // ]; }, onModeExit: ({ servicesManager }) => { const { @@ -131,6 +156,9 @@ function modeFactory() { cornerstoneViewportService, } = servicesManager.services; + _activatePanelTriggersSubscriptions.forEach(sub => sub.unsubscribe()); + _activatePanelTriggersSubscriptions = []; + toolbarService.reset(); toolGroupService.destroy(); syncGroupService.destroy(); @@ -162,7 +190,7 @@ function modeFactory() { props: { leftPanels: [tracked.thumbnailList], rightPanels: [dicomSeg.panel, tracked.measurements], - // rightPanelDefaultClosed: true, // optional prop to start with collapse panels + rightPanelDefaultClosed: true, viewports: [ { namespace: tracked.viewport, diff --git a/platform/core/src/extensions/ExtensionManager.ts b/platform/core/src/extensions/ExtensionManager.ts index 5827915cc..ca05871af 100644 --- a/platform/core/src/extensions/ExtensionManager.ts +++ b/platform/core/src/extensions/ExtensionManager.ts @@ -274,9 +274,9 @@ export default class ExtensionManager { // Default for most extension points, // Just adds each entry ready for consumption by mode. extensionModule.forEach(element => { - this.modulesMap[ - `${extensionId}.${moduleType}.${element.name}` - ] = element; + const id = `${extensionId}.${moduleType}.${element.name}`; + element.id = id; + this.modulesMap[id] = element; }); break; default: diff --git a/platform/core/src/index.test.js b/platform/core/src/index.test.js index 2fd94bf9b..3c058f961 100644 --- a/platform/core/src/index.test.js +++ b/platform/core/src/index.test.js @@ -40,6 +40,7 @@ describe('Top level exports', () => { 'DicomMetadataStore', 'pubSubServiceInterface', 'PubSubService', + 'PanelService', ].sort(); const exports = Object.keys(OHIF).sort(); diff --git a/platform/core/src/index.ts b/platform/core/src/index.ts index 35f809c57..5732d33f3 100644 --- a/platform/core/src/index.ts +++ b/platform/core/src/index.ts @@ -29,6 +29,7 @@ import { PubSubService, UserAuthenticationService, CustomizationService, + PanelService, } from './services'; import IWebApiDataSource from './DataSources/IWebApiDataSource'; @@ -74,6 +75,7 @@ const OHIF = { DicomMetadataStore, pubSubServiceInterface, PubSubService, + PanelService, }; export { @@ -112,6 +114,7 @@ export { pubSubServiceInterface, PubSubService, Types, + PanelService, }; export { OHIF }; diff --git a/platform/core/src/services/PanelService/PanelService.ts b/platform/core/src/services/PanelService/PanelService.ts new file mode 100644 index 000000000..c22194b11 --- /dev/null +++ b/platform/core/src/services/PanelService/PanelService.ts @@ -0,0 +1,61 @@ +import { ActivatePanelTriggers } from '../../types'; +import { Subscription } from '../../types/IPubSub'; +import { PubSubService } from '../_shared/pubSubServiceInterface'; + +export const EVENTS = { + ACTIVATE_PANEL: 'event::panelService:activatePanel', +}; + +export default class PanelService extends PubSubService { + public static REGISTRATION = { + name: 'panelService', + create: (): PanelService => { + return new PanelService(); + }, + }; + + constructor() { + super(EVENTS); + } + + /** + * Activates the panel with the given id. If the forceActive flag is false + * then it is up to the component containing the panel whether to activate + * it immediately or not. For instance, the panel might not be activated when + * the forceActive flag is false in the case where the user might have + * activated/displayed and then closed the panel already. + * Note that this method simply fires a broadcast event: ActivatePanelEvent. + * @param panelId the panel's id + * @param forceActive optional flag indicating if the panel should be forced to be activated or not + */ + activatePanel(panelId: string, forceActive = false): void { + this._broadcastEvent(EVENTS.ACTIVATE_PANEL, { panelId, forceActive }); + } + + /** + * Adds a mapping of events (activatePanelTriggers.sourceEvents) broadcast by + * activatePanelTrigger.sourcePubSubService that + * when fired/broadcasted must in turn activate the panel with the given id. + * The subscriptions created are returned such that they can be managed and unsubscribed + * as appropriate. + * @param panelId the id of the panel to activate + * @param activatePanelTriggers an array of triggers + * @param forceActive optional flag indicating if the panel should be forced to be activated or not + * @returns an array of the subscriptions subscribed to + */ + addActivatePanelTriggers( + panelId: string, + activatePanelTriggers: ActivatePanelTriggers[], + forceActive = false + ): Subscription[] { + return activatePanelTriggers + .map(trigger => + trigger.sourceEvents.map(eventName => + trigger.sourcePubSubService.subscribe(eventName, () => + this.activatePanel(panelId, forceActive) + ) + ) + ) + .flat(); + } +} diff --git a/platform/core/src/services/PanelService/index.ts b/platform/core/src/services/PanelService/index.ts new file mode 100644 index 000000000..0474f7412 --- /dev/null +++ b/platform/core/src/services/PanelService/index.ts @@ -0,0 +1,3 @@ +import PanelService from './PanelService'; + +export default PanelService; diff --git a/platform/core/src/services/index.ts b/platform/core/src/services/index.ts index 41a17d5be..6a3507992 100644 --- a/platform/core/src/services/index.ts +++ b/platform/core/src/services/index.ts @@ -17,6 +17,7 @@ import UserAuthenticationService from './UserAuthenticationService'; import CustomizationService from './CustomizationService'; import Services from '../types/Services'; +import PanelService from './PanelService'; export { Services, @@ -36,4 +37,5 @@ export { pubSubServiceInterface, PubSubService, UserAuthenticationService, + PanelService, }; diff --git a/platform/core/src/types/IPubSub.ts b/platform/core/src/types/IPubSub.ts index e7c4bc395..06e918374 100644 --- a/platform/core/src/types/IPubSub.ts +++ b/platform/core/src/types/IPubSub.ts @@ -1,6 +1,5 @@ import Consumer from './Consumer'; - export default interface IPubSub { subscribe: (eventName: string, callback: Consumer) => void; _broadcastEvent: ( @@ -10,3 +9,7 @@ export default interface IPubSub { _unsubscribe: (eventName: string, listenerId: string) => void; _isValidEvent: (eventName: string) => boolean; } + +export type Subscription = { + unsubscribe: () => void; +}; diff --git a/platform/core/src/types/PanelModule.ts b/platform/core/src/types/PanelModule.ts new file mode 100644 index 000000000..fe3e50139 --- /dev/null +++ b/platform/core/src/types/PanelModule.ts @@ -0,0 +1,30 @@ +import { PubSubService } from "../services"; + +type Panel = { + id?: string; + name: string; + iconName: string; + iconLabel: string; + label: string; + component: React.FC; +}; + +type ActivatePanelTriggers = { + sourcePubSubService: PubSubService; + sourceEvents: string[] +} + +interface PanelEvent { + panelId: string; +} + +interface ActivatePanelEvent extends PanelEvent { + forceActive: boolean; +} + +export type { + ActivatePanelEvent, + ActivatePanelTriggers, + Panel, + PanelEvent, +}; diff --git a/platform/core/src/types/Services.ts b/platform/core/src/types/Services.ts index aed77fbff..83b3f5ca8 100644 --- a/platform/core/src/types/Services.ts +++ b/platform/core/src/types/Services.ts @@ -27,4 +27,5 @@ export default interface Services { syncGroupService?: Record; cornerstoneCacheService?: Record; segmentationService?: Record; + panelService?: Record; } diff --git a/platform/core/src/types/index.ts b/platform/core/src/types/index.ts index f3229b98d..25df9b028 100644 --- a/platform/core/src/types/index.ts +++ b/platform/core/src/types/index.ts @@ -9,6 +9,8 @@ export * from './AppConfig'; export * from './Consumer'; export * from './Command'; export * from './StudyMetadata'; +export * from './PanelModule'; +export * from './IPubSub'; /** * Export the types used within the various services and managers, but diff --git a/platform/docs/docs/platform/extensions/modules/panel.md b/platform/docs/docs/platform/extensions/modules/panel.md index 1ad0d8c08..8704206c1 100644 --- a/platform/docs/docs/platform/extensions/modules/panel.md +++ b/platform/docs/docs/platform/extensions/modules/panel.md @@ -25,6 +25,10 @@ optionally provide its own `context` value. The `getPanelModule` receives an object containing the `ExtensionManager`'s associated `ServicesManager` and `CommandsManager`. +An extension can also trigger to activate/open a panel via the `PanelService` - +either by explicitly calling `PanelService.activatePanel` or triggering panel +activation when some other event fires. + ```jsx import PanelMeasurementTable from './PanelMeasurementTable.js'; @@ -64,7 +68,15 @@ providing the component with its. New: You can easily add multiple panels to the left/right side of the viewer using the mode configuration. As seen below, the `leftPanels` and `rightPanels` -accept an `Array` of the `IDs`. +accept an `Array` of the `IDs`. The mode configuration also allows for either (or +both) side panels to be closed by default. In the code below, the right panel +is closed by default. The mode can optionally add event triggers to +the `PanelService` that when fired will cause a side panel that was defaulted +closed to open. In the code below, the right side panel, that contains the +`trackedMeasurements` panel, is triggered to open when a measurement is added. +Note that once a default closed side panel has been opened once, +only a `PanelService.EVENTS.ACTIVATE_PANEL` event with `forceActive === true` +will cause it open (again). ```js @@ -79,6 +91,7 @@ const id = 'viewer' const version = '3.0.0 function modeFactory({ modeConfiguration }) { + let _activatePanelTriggersSubscriptions = []; return { id, routes: [ @@ -94,12 +107,35 @@ function modeFactory({ modeConfiguration }) { rightPanels: [ '@ohif/extension-measurement-tracking.panelModule.trackedMeasurements', ], + rightPanelDefaultClosed: true, viewports, }, }; }, }, ], + onModeEnter: ({ servicesManager }) => { + const { + measurementService, + panelService, + } = servicesManager.services; + + _activatePanelTriggersSubscriptions = [ + ...panelService.addActivatePanelTriggers('@ohif/extension-measurement-tracking.panelModule.trackedMeasurements', [ + { + sourcePubSubService: measurementService, + sourceEvents: [ + measurementService.EVENTS.MEASUREMENT_ADDED, + measurementService.EVENTS.RAW_MEASUREMENT_ADDED, + ], + }, + ]), + ]; + }, + onModeExit: () => { + _activatePanelTriggersSubscriptions.forEach(sub => sub.unsubscribe()); + _activatePanelTriggersSubscriptions = []; + }, extensions: extensionDependencies }; } diff --git a/platform/docs/docs/platform/services/data/PanelService.md b/platform/docs/docs/platform/services/data/PanelService.md new file mode 100644 index 000000000..b0a530b85 --- /dev/null +++ b/platform/docs/docs/platform/services/data/PanelService.md @@ -0,0 +1,49 @@ +--- +sidebar_position: 8 +sidebar_label: Panel Service +--- + +# Panel Service + +## Overview + +The Panel Service provides for activating/showing a panel that was registered +via the `getPanelModule` extension method. Such panels can be either explicitly +activated or implicitly triggered to activate when some other event occurs. + +## Events + +The following events are published in `PanelService`. + +| Event | Description | +| --------------------- | ------------------------------------------------------ | +| ACTIVATE__PANEL | Fires a `ActivatePanelEvent` when a particular panel should be activated (i.e. shown). | + + +## API + +### Panel Activation + +- `activatePanel`: Fires the `ACTIVATE_PANEL` event for a particular panel (id). +An optional `forceActive` flag can be passed that when `true` "forces" a +panel to show. Ultimately, it is up to a panel's container whether it +is appropriate to activate/show the panel. For instance, if the user opened and then +closed a side panel that contains the panel to activate, that side panel +may decide that the user knows best and will not open the panel (again). + +- `addActivatePanelTriggers`: Creates and returns event subscriptions that when +fired will activate the specified panel with an optional `forceActive` flag +(see `activatePanel`). This allows for panel activation to be directly triggered +by some other event(s). When the triggers are no longer needed, simply +unsubscribe to the returned subscriptions. For example, a panel +for tracking measurements might get activated every time the +`MeasurementService` fires a `MEASUREMENT_ADDED` event like this: + ```js + panelService.addActivatePanelTriggers('measurement-tracking-panel-id', [ + sourcePubSubService: measurementService, + sourceEvents: [ + measurementService.EVENTS.MEASUREMENT_ADDED, + measurementService.EVENTS.RAW_MEASUREMENT_ADDED, + ], + ]); + ``` diff --git a/platform/docs/docs/platform/services/data/index.md b/platform/docs/docs/platform/services/data/index.md index ae1589276..ee3ac507e 100644 --- a/platform/docs/docs/platform/services/data/index.md +++ b/platform/docs/docs/platform/services/data/index.md @@ -20,6 +20,7 @@ We maintain the following non-ui Services: - [Toolbar Service](../data/ToolBarService.md) - [Measurement Service](../data/MeasurementService.md) - [Customization Service](customization-service.md) +- [Panel Service](../data/PanelService.md) ## Service Architecture diff --git a/platform/ui/src/components/SidePanel/SidePanel.tsx b/platform/ui/src/components/SidePanel/SidePanel.tsx index 53be594a3..61128c90c 100644 --- a/platform/ui/src/components/SidePanel/SidePanel.tsx +++ b/platform/ui/src/components/SidePanel/SidePanel.tsx @@ -1,6 +1,6 @@ import classnames from 'classnames'; import PropTypes from 'prop-types'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import SwiperCore, { A11y, @@ -11,6 +11,8 @@ import SwiperCore, { } from 'swiper'; import { Swiper, SwiperSlide } from 'swiper/react'; +import { PanelService, ServicesManager, Types } from '@ohif/core'; + import { Button, Icon, IconButton, Tooltip } from '../'; import 'swiper/css'; @@ -67,15 +69,23 @@ const position = { }; const SidePanel = ({ + servicesManager, side, className, activeTabIndex: activeTabIndexProp, tabs, }) => { + const panelService: PanelService = servicesManager.services.panelService; + const { t } = useTranslation('SidePanel'); + // Tracks whether this SidePanel has been opened at least once since this SidePanel was inserted into the DOM. + // Thus going to the Study List page and back to the viewer resets this flag for a SidePanel. + const [hasBeenOpened, setHasBeenOpened] = useState( + activeTabIndexProp !== null + ); const [panelOpen, setPanelOpen] = useState(activeTabIndexProp !== null); - const [activeTabIndex, setActiveTabIndex] = useState(activeTabIndexProp || 0); + const [activeTabIndex, setActiveTabIndex] = useState(activeTabIndexProp ?? 0); const swiperRef = useRef() as any; const [swiper, setSwiper] = useState(); @@ -102,6 +112,41 @@ const SidePanel = ({ } }, [swiper]); + const updatePanelOpen = useCallback((panelOpen: boolean) => { + setPanelOpen(panelOpen); + if (panelOpen) { + setHasBeenOpened(true); + } + }, []); + + const updateActiveTabIndex = useCallback( + (activeTabIndex: number) => { + setActiveTabIndex(activeTabIndex); + updatePanelOpen(true); + }, + [updatePanelOpen] + ); + + useEffect(() => { + const activatePanelSubscription = panelService.subscribe( + panelService.EVENTS.ACTIVATE_PANEL, + (activatePanelEvent: Types.ActivatePanelEvent) => { + if (!hasBeenOpened || activatePanelEvent.forceActive) { + const tabIndex = tabs.findIndex( + tab => tab.id === activatePanelEvent.panelId + ); + if (tabIndex !== -1) { + updateActiveTabIndex(tabIndex); + } + } + } + ); + + return () => { + activatePanelSubscription.unsubscribe(); + }; + }, [tabs, hasBeenOpened, panelService, updateActiveTabIndex]); + const getCloseStateComponent = () => { const _childComponents = Array.isArray(tabs) ? tabs : [tabs]; return ( @@ -112,7 +157,7 @@ const SidePanel = ({ side === 'left' ? 'pr-2 justify-end' : 'pl-2 justify-start' )} onClick={() => { - setPanelOpen(prev => !prev); + updatePanelOpen(prev => !prev); }} data-cy={`side-panel-header-${side}`} > @@ -142,8 +187,7 @@ const SidePanel = ({ size="initial" className="text-primary-active" onClick={() => { - setActiveTabIndex(index); - setPanelOpen(true); + updateActiveTabIndex(index); }} > { - setPanelOpen(prev => !prev); + updatePanelOpen(prev => !prev); // slideToActivePanel(); }} data-cy={`side-panel-header-${side}`} @@ -215,8 +259,7 @@ const SidePanel = ({ nextRef, tabs, activeTabIndex, - setActiveTabIndex, - setPanelOpen + updateActiveTabIndex )} {/** carousel navigation with the arrows */} {/** only show carousel nav if tabs are more than 3 tabs */} @@ -250,6 +293,7 @@ SidePanel.defaultProps = { }; SidePanel.propTypes = { + servicesManager: PropTypes.instanceOf(ServicesManager), side: PropTypes.oneOf(['left', 'right']).isRequired, className: PropTypes.string, activeTabIndex: PropTypes.number, @@ -273,8 +317,7 @@ function _getMoreThanOneTabLayout( nextRef: React.MutableRefObject, tabs: any, activeTabIndex: any, - setActiveTabIndex: React.Dispatch, - setPanelOpen: React.Dispatch> + updateActiveTabIndex ) { return (
{ - setActiveTabIndex(index); - setPanelOpen(true); + updateActiveTabIndex(index); }} data-cy={`${obj.name}-btn`} > diff --git a/platform/viewer/cypress/integration/volume/MPR.spec.js b/platform/viewer/cypress/integration/volume/MPR.spec.js index d377a3fa3..19edfb965 100644 --- a/platform/viewer/cypress/integration/volume/MPR.spec.js +++ b/platform/viewer/cypress/integration/volume/MPR.spec.js @@ -27,9 +27,10 @@ describe('OHIF MPR', () => { }); it('should render correctly the MPR', () => { - cy.wait(1000); + cy.wait(250); cy.get(':nth-child(3) > [data-cy="study-browser-thumbnail"]').dblclick(); + cy.wait(250); cy.get('[data-cy="MPR"]').click(); cy.get('[data-cy="thumbnail-viewport-labels"]').should('have.length', 3); diff --git a/platform/viewer/src/appInit.js b/platform/viewer/src/appInit.js index a130a41ee..dbe623b6c 100644 --- a/platform/viewer/src/appInit.js +++ b/platform/viewer/src/appInit.js @@ -16,6 +16,7 @@ import { UserAuthenticationService, errorHandler, CustomizationService, + PanelService, // utils, } from '@ohif/core'; @@ -58,6 +59,7 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) { HangingProtocolService.REGISTRATION, CineService.REGISTRATION, UserAuthenticationService.REGISTRATION, + PanelService.REGISTRATION, ]); errorHandler.getHTTPErrorHandler = () => {