feat(toolbar): new Toolbar to enable reactive state synchronization (#3983)

This commit is contained in:
Alireza authored and GitHub committed 2024-03-27 16:01:32 -04:00
1 parent 79d5c36bda
commit 566b25a544
155 files changed
+6311 -6577

No files matched your search

@@ -72,7 +72,7 @@ describe('OHIF Cornerstone Toolbar', () => {
// Assign an alias to the button element
cy.get('@wwwcBtnPrimary').as('wwwcButton');
cy.get('@wwwcButton').click();
cy.get('@wwwcButton').should('have.class', 'active');
cy.get('@wwwcButton').should('have.class', 'bg-primary-light');
//drags the mouse inside the viewport to be able to interact with series
cy.get('@viewport')
@@ -97,7 +97,7 @@ describe('OHIF Cornerstone Toolbar', () => {
cy.get('@panButton').click();
// Assert that the button has the 'active' class
cy.get('@panButton').should('have.class', 'active');
cy.get('@panButton').should('have.class', 'bg-primary-light');
// Trigger the pan actions on the viewport
cy.get('@viewport')
@@ -418,44 +418,44 @@ describe('OHIF Cornerstone Toolbar', () => {
cy.get('@moreBtnSecondary').click();
//Click on Flip button
cy.get('[data-cy="flip-horizontal"]').click();
cy.get('[data-cy="flipHorizontal"]').click();
cy.waitDicomImage();
cy.get('@viewportInfoMidLeft').should('contains.text', 'L');
cy.get('@viewportInfoMidTop').should('contains.text', 'A');
});
it('checks if stack sync is preserved on new display set and uses FOR', () => {
// Active stack image sync and reference lines
cy.get('[data-cy="MoreTools-split-button-secondary"]').click();
cy.get('[data-cy="ImageSliceSync"]').click();
// Add reference lines as that sometimes throws an exception
cy.get('[data-cy="MoreTools-split-button-secondary"]').click();
cy.get('[data-cy="ReferenceLines"]').click();
// it('checks if stack sync is preserved on new display set and uses FOR', () => {
// // Active stack image sync and reference lines
// cy.get('[data-cy="MoreTools-split-button-secondary"]').click();
// cy.get('[data-cy="ImageSliceSync"]').click();
// // Add reference lines as that sometimes throws an exception
// cy.get('[data-cy="MoreTools-split-button-secondary"]').click();
// cy.get('[data-cy="ReferenceLines"]').click();
cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)').dblclick();
cy.get('body').type('{downarrow}{downarrow}');
// cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)').dblclick();
// cy.get('body').type('{downarrow}{downarrow}');
// Change the layout and double load the first
cy.setLayout(2, 1);
cy.get('body').type('{rightarrow}');
cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)').dblclick();
cy.waitDicomImage();
// // Change the layout and double load the first
// cy.setLayout(2, 1);
// cy.get('body').type('{rightarrow}');
// cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)').dblclick();
// cy.waitDicomImage();
// Now navigate down once and check that the left hand pane navigated
cy.get('body').focus().type('{downarrow}');
// // Now navigate down once and check that the left hand pane navigated
// cy.get('body').focus().type('{downarrow}');
// The following lines assist in troubleshooting when/if this test were to fail.
cy.get('[data-cy="viewport-pane"]')
.eq(0)
.find('[data-cy="viewport-overlay-top-right"]')
.should('contains.text', 'I:2 (2/20)');
cy.get('[data-cy="viewport-pane"]')
.eq(1)
.find('[data-cy="viewport-overlay-top-right"]')
.should('contains.text', 'I:2 (2/20)');
// // The following lines assist in troubleshooting when/if this test were to fail.
// cy.get('[data-cy="viewport-pane"]')
// .eq(0)
// .find('[data-cy="viewport-overlay-top-right"]')
// .should('contains.text', 'I:2 (2/20)');
// cy.get('[data-cy="viewport-pane"]')
// .eq(1)
// .find('[data-cy="viewport-overlay-top-right"]')
// .should('contains.text', 'I:2 (2/20)');
cy.get('body').type('{leftarrow}');
cy.setLayout(1, 1);
cy.get('@viewportInfoTopRight').should('contains.text', 'I:2 (2/20)');
});
// cy.get('body').type('{leftarrow}');
// cy.setLayout(1, 1);
// cy.get('@viewportInfoTopRight').should('contains.text', 'I:2 (2/20)');
// });
});
@@ -18,7 +18,7 @@ describe('OHIF General Viewer', function () {
cy.get('@zoomBtn')
.click()
.then($zoomBtn => {
cy.wrap($zoomBtn).should('have.class', 'active');
cy.wrap($zoomBtn).should('have.class', 'bg-primary-light');
});
const zoomLevelInitial = cy.get('@viewportInfoTopLeft').then($viewportInfo => {
@@ -7,8 +7,7 @@ describe('OHIF MPR', () => {
});
it('should not go MPR for non reconstructible displaySets', () => {
cy.get('[data-cy="MPR"]').click();
cy.get('.cornerstone-canvas').should('have.length', 1);
cy.get('[data-cy="MPR"]').should('have.class', 'ohif-disabled');
});
it('should go MPR for reconstructible displaySets and come back', () => {
@@ -88,7 +87,6 @@ describe('OHIF MPR', () => {
});
it('should correctly render Crosshairs for MPR', () => {
cy.get('[data-cy="Crosshairs"]').should('not.exist');
cy.get(':nth-child(3) > [data-cy="study-browser-thumbnail"]').dblclick();
cy.get('[data-cy="MPR"]').click();
cy.get('[data-cy="Crosshairs"]').click();
@@ -125,13 +123,7 @@ describe('OHIF MPR', () => {
cy.get('[data-cy="MPR"]').click();
cy.get('[data-cy="Crosshairs"]').click();
// wait for the crosshairs tool to be active
cy.get('[data-cy="Crosshairs"].active');
// Click the crosshairs button to deactivate it.
cy.get('[data-cy="Crosshairs"]').click();
// wait for the window level button to be active
cy.get('[data-cy="WindowLevel-split-button-primary"].active');
});
});
+1 -1
View File
@@ -273,7 +273,7 @@ Cypress.Commands.add(
}
});
cy.get('@lengthButton').should('have.class', 'active');
cy.get('@lengthButton').should('have.class', 'bg-primary-light');
cy.get('@viewport').then($viewport => {
const [x1, y1] = firstClick;
+3
View File
@@ -10,6 +10,9 @@ window.config = {
strictZSpacingForVolumeViewport: true,
// filterQueryParam: false,
defaultDataSourceName: 'e2e',
investigationalUseDialog: {
option: 'never',
},
dataSources: [
{
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
+2
View File
@@ -16,6 +16,7 @@ import {
ViewportGridProvider,
CineProvider,
UserAuthenticationProvider,
ToolboxProvider,
} from '@ohif/ui';
// Viewer Project
// TODO: Should this influence study list?
@@ -69,6 +70,7 @@ function App({ config, defaultExtensions, defaultModes }) {
[UserAuthenticationProvider, { service: userAuthenticationService }],
[I18nextProvider, { i18n }],
[ThemeWrapper],
[ToolboxProvider],
[ViewportGridProvider, { service: viewportGridService }],
[ViewportDialogProvider, { service: uiViewportDialogService }],
[CineProvider, { service: cineService }],
+2
View File
@@ -49,6 +49,8 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
appConfig,
});
servicesManager.setExtensionManager(extensionManager);
servicesManager.registerServices([
UINotificationService.REGISTRATION,
UIModalService.REGISTRATION,
+17 -2
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useCallback, useRef } from 'react';
import React, { useEffect, useCallback, useRef, useState } from 'react';
import ReactResizeDetector from 'react-resize-detector';
import PropTypes from 'prop-types';
import { ServicesManager, Types, MeasurementService } from '@ohif/core';
@@ -15,12 +15,15 @@ function ViewerViewportGrid(props) {
const { layout, activeViewportId, viewports } = viewportGrid;
const { numCols, numRows } = layout;
const elementRef = useRef(null);
const layoutHash = useRef(null);
// TODO -> Need some way of selecting which displaySets hit the viewports.
const { displaySetService, measurementService, hangingProtocolService, uiNotificationService } = (
servicesManager as ServicesManager
).services;
const generateLayoutHash = () => `${numCols}-${numRows}`;
/**
* This callback runs after the viewports structure has changed in any way.
* On initial display, that means if it has changed by applying a HangingProtocol,
@@ -135,6 +138,16 @@ function ViewerViewportGrid(props) {
};
}, []);
// Check viewport readiness in useEffect
useEffect(() => {
const allReady = viewportGridService.getGridViewportsReady();
const sameLayoutHash = layoutHash.current === generateLayoutHash();
if (allReady && !sameLayoutHash) {
layoutHash.current = generateLayoutHash();
viewportGridService.publishViewportsReady();
}
}, [viewportGridService, generateLayoutHash]);
useEffect(() => {
const { unsubscribe } = measurementService.subscribe(
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_LAYOUT,
@@ -327,6 +340,9 @@ function ViewerViewportGrid(props) {
viewportOptions={viewportOptions}
displaySetOptions={displaySetOptions}
needsRerendering={displaySetsNeedsRerendering}
onReady={() => {
viewportGridService.setViewportIsReady(viewportId, true);
}}
/>
</div>
</ViewportPane>
@@ -360,7 +376,6 @@ function ViewerViewportGrid(props) {
}}
targetRef={elementRef.current}
/>
{/* {ViewportPanes} */}
{getViewportPanes()}
</ViewportGrid>
</div>
+67 -196
View File
@@ -1,141 +1,18 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef, useContext, createContext } from 'react';
import { useParams, useLocation, useNavigate } from 'react-router';
import PropTypes from 'prop-types';
// TODO: DicomMetadataStore should be injected?
import { DicomMetadataStore, ServicesManager, utils, log, Enums } from '@ohif/core';
import { ServicesManager, utils } from '@ohif/core';
import { DragAndDropProvider, ImageViewerProvider } from '@ohif/ui';
import { useSearchParams } from '@hooks';
import { useAppConfig } from '@state';
import ViewportGrid from '@components/ViewportGrid';
import Compose from './Compose';
import getStudies from './studiesList';
import { history } from '../../utils/history';
import loadModules from '../../pluginImports';
import isSeriesFilterUsed from '../../utils/isSeriesFilterUsed';
import { defaultRouteInit } from './defaultRouteInit';
import { updateAuthServiceAndCleanUrl } from './updateAuthServiceAndCleanUrl';
const { getSplitParam, sortingCriteria } = utils;
/**
* Initialize the route.
*
* @param props.servicesManager to read services from
* @param props.studyInstanceUIDs for a list of studies to read
* @param props.dataSource to read the data from
* @param props.filters filters from query params to read the data from
* @returns array of subscriptions to cancel
*/
function defaultRouteInit(
{ servicesManager, studyInstanceUIDs, dataSource, filters, appConfig },
hangingProtocolId
) {
const { displaySetService, hangingProtocolService, uiNotificationService, customizationService } =
servicesManager.services;
/**
* Function to apply the hanging protocol when the minimum number of display sets were
* received or all display sets retrieval were completed
* @returns
*/
function applyHangingProtocol() {
const displaySets = displaySetService.getActiveDisplaySets();
if (!displaySets || !displaySets.length) {
return;
}
// Gets the studies list to use
const studies = getStudies(studyInstanceUIDs, displaySets);
// study being displayed, and is thus the "active" study.
const activeStudy = studies[0];
// run the hanging protocol matching on the displaySets with the predefined
// hanging protocol in the mode configuration
hangingProtocolService.run({ studies, activeStudy, displaySets }, hangingProtocolId);
}
const unsubscriptions = [];
const issuedWarningSeries = [];
const { unsubscribe: instanceAddedUnsubscribe } = DicomMetadataStore.subscribe(
DicomMetadataStore.EVENTS.INSTANCES_ADDED,
function ({ StudyInstanceUID, SeriesInstanceUID, madeInClient = false }) {
const seriesMetadata = DicomMetadataStore.getSeries(StudyInstanceUID, SeriesInstanceUID);
// checks if the series filter was used, if it exists
const seriesInstanceUIDs = filters?.seriesInstanceUID;
if (
seriesInstanceUIDs?.length &&
!isSeriesFilterUsed(seriesMetadata.instances, filters) &&
!issuedWarningSeries.includes(seriesInstanceUIDs[0])
) {
// stores the series instance filter so it shows only once the warning
issuedWarningSeries.push(seriesInstanceUIDs[0]);
uiNotificationService.show({
title: 'Series filter',
message: `Each of the series in filter: ${seriesInstanceUIDs} are not part of the current study. The entire study is being displayed`,
type: 'error',
duration: 7000,
});
}
displaySetService.makeDisplaySets(seriesMetadata.instances, madeInClient);
}
);
unsubscriptions.push(instanceAddedUnsubscribe);
log.time(Enums.TimingEnum.STUDY_TO_DISPLAY_SETS);
log.time(Enums.TimingEnum.STUDY_TO_FIRST_IMAGE);
const allRetrieves = studyInstanceUIDs.map(StudyInstanceUID =>
dataSource.retrieve.series.metadata({
StudyInstanceUID,
filters,
returnPromises: true,
sortCriteria:
customizationService.get('sortingCriteria') ||
sortingCriteria.seriesSortCriteria.seriesInfoSortingCriteria,
})
);
// log the error if this fails, otherwise it's so difficult to tell what went wrong...
allRetrieves.forEach(retrieve => {
retrieve.catch(error => {
console.error(error);
});
});
Promise.allSettled(allRetrieves).then(promises => {
log.timeEnd(Enums.TimingEnum.STUDY_TO_DISPLAY_SETS);
log.time(Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE);
log.time(Enums.TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES);
const allPromises = [];
const remainingPromises = [];
function startRemainingPromises(remainingPromises) {
remainingPromises.forEach(p => p.forEach(p => p.start()));
}
promises.forEach(promise => {
const retrieveSeriesMetadataPromise = promise.value;
if (Array.isArray(retrieveSeriesMetadataPromise)) {
const { requiredSeries, remaining } = hangingProtocolService.filterSeriesRequiredForRun(
hangingProtocolId,
retrieveSeriesMetadataPromise
);
const requiredSeriesPromises = requiredSeries.map(promise => promise.start());
allPromises.push(Promise.allSettled(requiredSeriesPromises));
remainingPromises.push(remaining);
}
});
Promise.allSettled(allPromises).then(applyHangingProtocol);
startRemainingPromises(remainingPromises);
applyHangingProtocol();
});
return unsubscriptions;
}
const { getSplitParam } = utils;
export default function ModeRoute({
mode,
@@ -166,7 +43,7 @@ export default function ModeRoute({
// The URL's query search parameters where the keys are all lower case.
const lowerCaseSearchParams = useSearchParams({ lowerCaseKeys: true });
const [studyInstanceUIDs, setStudyInstanceUIDs] = useState();
const [studyInstanceUIDs, setStudyInstanceUIDs] = useState(null);
const [refresh, setRefresh] = useState(false);
const [ExtensionDependenciesLoaded, setExtensionDependenciesLoaded] = useState(false);
@@ -193,25 +70,7 @@ export default function ModeRoute({
const token = lowerCaseSearchParams.get('token');
if (token) {
// if a token is passed in, set the userAuthenticationService to use it
// for the Authorization header for all requests
userAuthenticationService.setServiceImplementation({
getAuthorizationHeader: () => ({
Authorization: 'Bearer ' + token,
}),
});
// Create a URL object with the current location
const urlObj = new URL(window.location.origin + window.location.pathname + location.search);
// Remove the token from the URL object
urlObj.searchParams.delete('token');
const cleanUrl = urlObj.toString();
// Update the browser's history without the token
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', cleanUrl);
}
updateAuthServiceAndCleanUrl(token, location, userAuthenticationService);
}
// Preserve the old array interface for hotkeys
@@ -228,32 +87,6 @@ export default function ModeRoute({
// Only handling one route per mode for now
const route = mode.routes[0];
// For each extension, look up their context modules
// TODO: move to extension manager.
let contextModules = [];
Object.keys(extensions).forEach(extensionId => {
const allRegisteredModuleIds = Object.keys(extensionManager.modulesMap);
const moduleIds = allRegisteredModuleIds.filter(id =>
id.includes(`${extensionId}.contextModule.`)
);
if (!moduleIds || !moduleIds.length) {
return;
}
const modules = moduleIds.map(extensionManager.getModuleEntry);
contextModules = contextModules.concat(modules);
});
const contextModuleProviders = contextModules.map(a => a.provider);
const CombinedContextProvider = ({ children }) =>
Compose({ components: contextModuleProviders, children });
function ViewportGridWithDataSource(props) {
return ViewportGrid({ ...props, dataSource });
}
useEffect(() => {
const loadExtensions = async () => {
const loadedExtensions = await loadModules(Object.keys(extensions));
@@ -298,7 +131,7 @@ export default function ModeRoute({
}, [location, ExtensionDependenciesLoaded]);
useEffect(() => {
if (!ExtensionDependenciesLoaded) {
if (!ExtensionDependenciesLoaded || !studyInstanceUIDs?.length) {
return;
}
@@ -313,7 +146,7 @@ export default function ModeRoute({
setRefresh(!refresh);
}
};
if (studyInstanceUIDs?.length && studyInstanceUIDs[0] !== undefined) {
if (Array.isArray(studyInstanceUIDs) && studyInstanceUIDs[0]) {
retrieveLayoutData();
}
return () => {
@@ -322,7 +155,7 @@ export default function ModeRoute({
}, [studyInstanceUIDs, ExtensionDependenciesLoaded]);
useEffect(() => {
if (!hotkeys || !ExtensionDependenciesLoaded) {
if (!hotkeys || !ExtensionDependenciesLoaded || !studyInstanceUIDs?.length) {
return;
}
@@ -339,10 +172,10 @@ export default function ModeRoute({
return () => {
hotkeysManager.destroy();
};
}, [ExtensionDependenciesLoaded]);
}, [ExtensionDependenciesLoaded, hotkeys, studyInstanceUIDs]);
useEffect(() => {
if (!layoutTemplateData.current || !ExtensionDependenciesLoaded) {
if (!layoutTemplateData.current || !ExtensionDependenciesLoaded || !studyInstanceUIDs?.length) {
return;
}
@@ -477,7 +310,21 @@ export default function ModeRoute({
refresh,
]);
const renderLayoutData = props => {
if (!studyInstanceUIDs || !layoutTemplateData.current || !ExtensionDependenciesLoaded) {
return null;
}
const ViewportGridWithDataSource = props => {
return ViewportGrid({ ...props, dataSource });
};
const CombinedExtensionsContextProvider = createCombinedContextProvider(
extensionManager,
servicesManager,
commandsManager
);
const getLayoutComponent = props => {
const layoutTemplateModuleEntry = extensionManager.getModuleEntry(
layoutTemplateData.current.id
);
@@ -486,27 +333,51 @@ export default function ModeRoute({
return <LayoutComponent {...props} />;
};
const LayoutComponent = getLayoutComponent({
...layoutTemplateData.current.props,
ViewportGridComp: ViewportGridWithDataSource,
});
return (
<ImageViewerProvider
// initialState={{ StudyInstanceUIDs: StudyInstanceUIDs }}
StudyInstanceUIDs={studyInstanceUIDs}
// reducer={reducer}
>
<CombinedContextProvider>
<DragAndDropProvider>
{layoutTemplateData.current &&
studyInstanceUIDs?.[0] !== undefined &&
ExtensionDependenciesLoaded &&
renderLayoutData({
...layoutTemplateData.current.props,
ViewportGridComp: ViewportGridWithDataSource,
})}
</DragAndDropProvider>
</CombinedContextProvider>
<ImageViewerProvider StudyInstanceUIDs={studyInstanceUIDs}>
{CombinedExtensionsContextProvider ? (
<CombinedExtensionsContextProvider>
<DragAndDropProvider>{LayoutComponent}</DragAndDropProvider>
</CombinedExtensionsContextProvider>
) : (
<DragAndDropProvider>{LayoutComponent}</DragAndDropProvider>
)}
</ImageViewerProvider>
);
}
/**
* Creates a combined context provider using the context modules from the extension manager.
* @param {object} extensionManager - The extension manager instance.
* @param {object} servicesManager - The services manager instance.
* @param {object} commandsManager - The commands manager instance.
* @returns {React.Component} - A React component that provides combined contexts to its children.
*/
function createCombinedContextProvider(extensionManager, servicesManager, commandsManager) {
const extensionsContextModules = extensionManager.getModulesByType(
extensionManager.constructor.MODULE_TYPES.CONTEXT
);
if (!extensionsContextModules?.length) {
return;
}
const contextModuleProviders = extensionsContextModules.flatMap(({ module }) => {
return module.map(aContextModule => {
return aContextModule.provider;
});
});
return ({ children }) => {
return Compose({ components: contextModuleProviders, children });
};
}
ModeRoute.propTypes = {
mode: PropTypes.object.isRequired,
dataSourceName: PropTypes.string,
@@ -0,0 +1,129 @@
import getStudies from './studiesList';
import { DicomMetadataStore, log } from '@ohif/core';
import isSeriesFilterUsed from '../../utils/isSeriesFilterUsed';
import { utils, Enums } from '@ohif/core';
const { sortingCriteria } = utils;
/**
* Initialize the route.
*
* @param props.servicesManager to read services from
* @param props.studyInstanceUIDs for a list of studies to read
* @param props.dataSource to read the data from
* @param props.filters filters from query params to read the data from
* @returns array of subscriptions to cancel
*/
export function defaultRouteInit(
{ servicesManager, studyInstanceUIDs, dataSource, filters, appConfig },
hangingProtocolId
) {
const { displaySetService, hangingProtocolService, uiNotificationService, customizationService } =
servicesManager.services;
/**
* Function to apply the hanging protocol when the minimum number of display sets were
* received or all display sets retrieval were completed
* @returns
*/
function applyHangingProtocol() {
const displaySets = displaySetService.getActiveDisplaySets();
if (!displaySets || !displaySets.length) {
return;
}
// Gets the studies list to use
const studies = getStudies(studyInstanceUIDs, displaySets);
// study being displayed, and is thus the "active" study.
const activeStudy = studies[0];
// run the hanging protocol matching on the displaySets with the predefined
// hanging protocol in the mode configuration
hangingProtocolService.run({ studies, activeStudy, displaySets }, hangingProtocolId);
}
const unsubscriptions = [];
const issuedWarningSeries = [];
const { unsubscribe: instanceAddedUnsubscribe } = DicomMetadataStore.subscribe(
DicomMetadataStore.EVENTS.INSTANCES_ADDED,
function ({ StudyInstanceUID, SeriesInstanceUID, madeInClient = false }) {
const seriesMetadata = DicomMetadataStore.getSeries(StudyInstanceUID, SeriesInstanceUID);
// checks if the series filter was used, if it exists
const seriesInstanceUIDs = filters?.seriesInstanceUID;
if (
seriesInstanceUIDs?.length &&
!isSeriesFilterUsed(seriesMetadata.instances, filters) &&
!issuedWarningSeries.includes(seriesInstanceUIDs[0])
) {
// stores the series instance filter so it shows only once the warning
issuedWarningSeries.push(seriesInstanceUIDs[0]);
uiNotificationService.show({
title: 'Series filter',
message: `Each of the series in filter: ${seriesInstanceUIDs} are not part of the current study. The entire study is being displayed`,
type: 'error',
duration: 7000,
});
}
displaySetService.makeDisplaySets(seriesMetadata.instances, madeInClient);
}
);
unsubscriptions.push(instanceAddedUnsubscribe);
log.time(Enums.TimingEnum.STUDY_TO_DISPLAY_SETS);
log.time(Enums.TimingEnum.STUDY_TO_FIRST_IMAGE);
const allRetrieves = studyInstanceUIDs.map(StudyInstanceUID =>
dataSource.retrieve.series.metadata({
StudyInstanceUID,
filters,
returnPromises: true,
sortCriteria:
customizationService.get('sortingCriteria') ||
sortingCriteria.seriesSortCriteria.seriesInfoSortingCriteria,
})
);
// log the error if this fails, otherwise it's so difficult to tell what went wrong...
allRetrieves.forEach(retrieve => {
retrieve.catch(error => {
console.error(error);
});
});
Promise.allSettled(allRetrieves).then(promises => {
log.timeEnd(Enums.TimingEnum.STUDY_TO_DISPLAY_SETS);
log.time(Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE);
log.time(Enums.TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES);
const allPromises = [];
const remainingPromises = [];
function startRemainingPromises(remainingPromises) {
remainingPromises.forEach(p => p.forEach(p => p.start()));
}
promises.forEach(promise => {
const retrieveSeriesMetadataPromise = promise.value;
if (Array.isArray(retrieveSeriesMetadataPromise)) {
const { requiredSeries, remaining } = hangingProtocolService.filterSeriesRequiredForRun(
hangingProtocolId,
retrieveSeriesMetadataPromise
);
const requiredSeriesPromises = requiredSeries.map(promise => promise.start());
allPromises.push(Promise.allSettled(requiredSeriesPromises));
remainingPromises.push(remaining);
}
});
Promise.allSettled(allPromises).then(applyHangingProtocol);
startRemainingPromises(remainingPromises);
applyHangingProtocol();
});
return unsubscriptions;
}
@@ -0,0 +1,35 @@
/**
* Updates the user authentication service with the provided token and cleans the token from the URL.
* @param token - The token to set in the user authentication service.
* @param location - The location object from the router.
* @param userAuthenticationService - The user authentication service instance.
*/
export function updateAuthServiceAndCleanUrl(
token: string,
location: any,
userAuthenticationService: any
): void {
if (!token) {
return;
}
// if a token is passed in, set the userAuthenticationService to use it
// for the Authorization header for all requests
userAuthenticationService.setServiceImplementation({
getAuthorizationHeader: () => ({
Authorization: 'Bearer ' + token,
}),
});
// Create a URL object with the current location
const urlObj = new URL(window.location.origin + window.location.pathname + location.search);
// Remove the token from the URL object
urlObj.searchParams.delete('token');
const cleanUrl = urlObj.toString();
// Update the browser's history without the token
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', cleanUrl);
}
}
@@ -419,7 +419,7 @@ function WorkList({
/>
} // launch-arrow | launch-info
onClick={() => {}}
data-cy={`mode-${mode.routeName}-${studyInstanceUid}`}
dataCY={`mode-${mode.routeName}-${studyInstanceUid}`}
className={isValidMode ? 'text-[13px]' : 'bg-[#222d44] text-[13px]'}
>
{mode.displayName}
+1 -31
View File
@@ -48,37 +48,7 @@ function modeFactory({ modeConfiguration }) {
// Init Default and SR ToolGroups
initToolGroups(extensionManager, toolGroupService, commandsManager);
let unsubscribe;
const activateTool = () => {
toolbarService.recordInteraction({
groupId: 'WindowLevel',
interactionType: 'tool',
commands: [
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'WindowLevel',
},
context: 'CORNERSTONE',
},
],
});
// We don't need to reset the active tool whenever a viewport is getting
// added to the toolGroup.
unsubscribe();
};
// Since we only have one viewport for the basic cs3d mode and it has
// only one hanging protocol, we can just use the first viewport
({ unsubscribe } = toolGroupService.subscribe(
toolGroupService.EVENTS.VIEWPORT_ADDED,
activateTool
));
toolbarService.init(extensionManager);
toolbarService.addButtons([...toolbarButtons, ...moreTools]);
toolbarService.addButtons(toolbarButtons);
toolbarService.createButtonSection('primary', [
'MeasurementTools',
'Zoom',
@@ -8,7 +8,6 @@ describe('CommandsManager', () => {
contextName = 'VTK',
command = {
commandFn: jest.fn().mockReturnValue(true),
storeContexts: ['viewers'],
options: { passMeToCommandFn: ':wave:' },
},
commandsManagerConfig = {
@@ -139,7 +138,6 @@ describe('CommandsManager', () => {
it('Logs a warning if command definition does not have a commandFn', () => {
const commandWithNoCommmandFn = {
commandFn: undefined,
storeContexts: [],
options: {},
};
+26 -10
View File
@@ -1,5 +1,5 @@
import log from '../log.js';
import { Command, Commands } from '../types/Command';
import { Command, Commands, ComplexCommand } from '../types/Command';
/**
* The definition of a command
@@ -167,28 +167,44 @@ export class CommandsManager {
* Run one or more commands with specified extra options.
* Returns the result of the last command run.
*
* @param toRun - A specification of one or more commands
* @param toRun - A specification of one or more commands,
* typically an object of { commandName, commandOptions, context }
* or an array of such objects. It can also be a single commandName as string
* if no options are needed.
* @param options - to include in the commands run beyond
* the commandOptions specified in the base.
*/
public run(
toRun: Command | Commands | Command[] | undefined,
toRun: Command | Commands | Command[] | string | undefined,
options?: Record<string, unknown>
): unknown {
if (!toRun) {
return;
}
const commands =
(Array.isArray(toRun) && toRun) ||
((toRun as Command).commandName && [toRun]) ||
(Array.isArray((toRun as Commands).commands) && (toRun as Commands).commands);
if (!commands) {
// Normalize `toRun` to an array of `ComplexCommand`
let commands: ComplexCommand[] = [];
if (typeof toRun === 'string') {
commands = [{ commandName: toRun }];
} else if ('commandName' in toRun) {
commands = [toRun as ComplexCommand];
} else if ('commands' in toRun) {
const commandsInput = (toRun as Commands).commands;
commands = Array.isArray(commandsInput)
? commandsInput.map(cmd => (typeof cmd === 'string' ? { commandName: cmd } : cmd))
: [{ commandName: commandsInput }];
} else if (Array.isArray(toRun)) {
commands = toRun.map(cmd => (typeof cmd === 'string' ? { commandName: cmd } : cmd));
}
if (commands.length === 0) {
console.log("Command isn't runnable", toRun);
return;
}
let result;
(commands as Command[]).forEach(({ commandName, commandOptions, context }) => {
// Execute each command in the array
let result: unknown;
commands.forEach(({ commandName, commandOptions, context }) => {
if (commandName) {
result = this.runCommand(
commandName,
@@ -1,5 +1,4 @@
import objectHash from 'object-hash';
import log from '../log.js';
import { hotkeys } from '../utils';
import isequal from 'lodash.isequal';
import Hotkey from './Hotkey';
@@ -276,10 +275,3 @@ export class HotkeysManager {
}
export default HotkeysManager;
// Commands Contexts:
// --> Name and Priority
// GLOBAL: 0
// VIEWER::CORNERSTONE: 1
// VIEWER::VTK: 1
@@ -247,7 +247,6 @@ describe('ExtensionManager.ts', () => {
definitions: {
exampleDefinition: {
commandFn: () => {},
storeContexts: [],
options: {},
},
},
+105 -40
View File
@@ -45,6 +45,7 @@ export interface Extension {
getUtilityModule?: (p: ExtensionParams) => unknown;
getCustomizationModule?: (p: ExtensionParams) => unknown;
getSopClassHandlerModule?: (p: ExtensionParams) => unknown;
getToolbarModule?: (p: ExtensionParams) => unknown;
onModeEnter?: () => void;
onModeExit?: () => void;
}
@@ -65,9 +66,24 @@ export default class ExtensionManager extends PubSubService {
ACTIVE_DATA_SOURCE_CHANGED: 'event::activedatasourcechanged',
};
public static readonly MODULE_TYPES = MODULE_TYPES;
private _commandsManager: CommandsManager;
private _servicesManager: ServicesManager;
private _hotkeysManager: HotkeysManager;
private modulesMap: Record<string, unknown>;
private modules: Record<string, any[]>;
private registeredExtensionIds: string[];
private moduleTypeNames: string[];
private _appConfig: any;
private _extensionLifeCycleHooks: {
onModeEnter: Record<string, any>;
onModeExit: Record<string, any>;
};
private dataSourceMap: Record<string, any>;
private dataSourceDefs: Record<string, any>;
private defaultDataSourceName: string;
private activeDataSource: string;
constructor({
commandsManager,
@@ -265,57 +281,74 @@ export default class ExtensionManager extends PubSubService {
configuration
);
if (extensionModule) {
switch (moduleType) {
case MODULE_TYPES.COMMANDS:
this._initCommandsModule(extensionModule);
break;
case MODULE_TYPES.DATA_SOURCE:
this._initDataSourcesModule(extensionModule, extensionId, dataSources);
break;
case MODULE_TYPES.HANGING_PROTOCOL:
this._initHangingProtocolsModule(extensionModule, extensionId);
case MODULE_TYPES.TOOLBAR:
case MODULE_TYPES.VIEWPORT:
case MODULE_TYPES.PANEL:
case MODULE_TYPES.SOP_CLASS_HANDLER:
case MODULE_TYPES.CONTEXT:
case MODULE_TYPES.LAYOUT_TEMPLATE:
case MODULE_TYPES.CUSTOMIZATION:
case MODULE_TYPES.STATE_SYNC:
case MODULE_TYPES.UTILITY:
// Default for most extension points,
// Just adds each entry ready for consumption by mode.
extensionModule.forEach(element => {
if (!element.name) {
throw new Error(
`Extension ID ${extensionId} module ${moduleType} element has no name`
);
}
const id = `${extensionId}.${moduleType}.${element.name}`;
element.id = id;
this.modulesMap[id] = element;
});
break;
default:
throw new Error(`Module type invalid: ${moduleType}`);
}
this.modules[moduleType].push({
extensionId,
module: extensionModule,
});
if (!extensionModule) {
return;
}
switch (moduleType) {
case MODULE_TYPES.COMMANDS:
this._initCommandsModule(extensionModule);
break;
case MODULE_TYPES.DATA_SOURCE:
this._initDataSourcesModule(extensionModule, extensionId, dataSources);
break;
case MODULE_TYPES.HANGING_PROTOCOL:
this._initHangingProtocolsModule(extensionModule, extensionId);
break;
case MODULE_TYPES.PANEL:
this._initPanelModule(extensionModule, extensionId);
break;
case MODULE_TYPES.TOOLBAR:
this._initToolbarModule(extensionModule, extensionId);
break;
case MODULE_TYPES.VIEWPORT:
case MODULE_TYPES.SOP_CLASS_HANDLER:
case MODULE_TYPES.CONTEXT:
case MODULE_TYPES.LAYOUT_TEMPLATE:
case MODULE_TYPES.CUSTOMIZATION:
case MODULE_TYPES.STATE_SYNC:
case MODULE_TYPES.UTILITY:
this.processExtensionModule(extensionModule, extensionId, moduleType);
break;
default:
throw new Error(`Module type invalid: ${moduleType}`);
}
this.modules[moduleType].push({
extensionId,
module: extensionModule,
});
});
// Track extension registration
this.registeredExtensionIds.push(extensionId);
};
/**
* Retrieves the module entry associated with the given string entry
* @param stringEntry - The string entry to retrieve the module entry for which is
* in the format of `${extensionId}.${moduleType}.${moduleName}`
* @returns The module entry associated with the given string entry.
*/
getModuleEntry = stringEntry => {
return this.modulesMap[stringEntry];
};
/**
* Retrieves all modules of a given type for all registered extensions.
*
* @param moduleType - The type of modules to retrieve.
* @returns An array of modules of the specified type.
*/
getModulesByType = (moduleType: string) => {
return this.modules[moduleType];
};
getDataSources = dataSourceName => {
if (dataSourceName === undefined) {
// Default to the activeDataSource
@@ -402,6 +435,38 @@ export default class ExtensionManager extends PubSubService {
});
};
_initPanelModule = (extensionModule, extensionId) => {
this.processExtensionModule(extensionModule, extensionId, MODULE_TYPES.PANEL);
};
_initToolbarModule = (extensionModule, extensionId) => {
// check if the toolbar module has a handler function for evaluation of
// the toolbar button state
const { toolbarService } = this._servicesManager.services;
extensionModule.forEach(toolbarButton => {
if (toolbarButton.evaluate) {
toolbarService.registerEvaluateFunction(toolbarButton.name, toolbarButton.evaluate);
}
});
};
/**
* Processes an extension module.
* @param extensionModule - The extension module to process.
* @param extensionId - The ID of the extension.
* @param moduleType - The type of the module.
*/
private processExtensionModule(extensionModule, extensionId: string, moduleType: string) {
extensionModule.forEach(element => {
if (!element.name) {
throw new Error(`Extension ID ${extensionId} module ${moduleType} element has no name`);
}
const id = `${extensionId}.${moduleType}.${element.name}`;
element.id = id;
this.modulesMap[id] = element;
});
}
/**
* Adds the given data source and optionally sets it as the active data source.
* The method does this by first creating the data source.
+58
View File
@@ -0,0 +1,58 @@
import { useCallback, useEffect, useState } from 'react';
export function useToolbar({ servicesManager, buttonSection = 'primary' }) {
const { toolbarService, viewportGridService } = servicesManager.services;
const { EVENTS } = toolbarService;
const [toolbarButtons, setToolbarButtons] = useState(
toolbarService.getButtonSection(buttonSection)
);
// Callback function for handling toolbar interactions
const onInteraction = useCallback(
args => {
const viewportId = viewportGridService.getActiveViewportId();
const refreshProps = {
viewportId,
};
toolbarService.recordInteraction(args, {
refreshProps,
});
},
[toolbarService, viewportGridService]
);
// Effect to handle toolbar modification events
useEffect(() => {
const handleToolbarModified = () => {
setToolbarButtons(toolbarService.getButtonSection(buttonSection));
};
const subs = [EVENTS.TOOL_BAR_MODIFIED, EVENTS.TOOL_BAR_STATE_MODIFIED].map(event => {
return toolbarService.subscribe(event, handleToolbarModified);
});
return () => {
subs.forEach(sub => sub.unsubscribe());
};
}, [toolbarService]);
// Effect to handle active viewportId change event
useEffect(() => {
const events = [
viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED,
viewportGridService.EVENTS.VIEWPORTS_READY,
];
const subscriptions = events.map(event => {
return viewportGridService.subscribe(event, ({ viewportId }) => {
viewportId = viewportId || viewportGridService.getActiveViewportId();
toolbarService.refreshToolbarState({ viewportId });
});
});
return () => subscriptions.forEach(sub => sub.unsubscribe());
}, [viewportGridService, toolbarService]);
return { toolbarButtons, onInteraction };
}
+1
View File
@@ -45,6 +45,7 @@ describe('Top level exports', () => {
'pubSubServiceInterface',
'PubSubService',
'PanelService',
'useToolbar',
].sort();
const exports = Object.keys(OHIF).sort();
+3
View File
@@ -12,6 +12,7 @@ import utils from './utils';
import defaults from './defaults';
import * as Types from './types';
import * as Enums from './enums';
import { useToolbar } from './hooks/useToolbar';
import {
CineService,
@@ -81,6 +82,7 @@ const OHIF = {
pubSubServiceInterface,
PubSubService,
PanelService,
useToolbar,
};
export {
@@ -124,6 +126,7 @@ export {
Enums,
Types,
PanelService,
useToolbar,
};
export { OHIF };
@@ -1,82 +1,80 @@
const name = 'CineService';
import { PubSubService } from '../_shared/pubSubServiceInterface';
const publicAPI = {
name,
getState: _getState,
setCine: _setCine,
setIsCineEnabled: _setIsCineEnabled,
playClip: _playClip,
stopClip: _stopClip,
onModeExit: _onModeExit,
setServiceImplementation,
};
class CineService extends PubSubService {
public static readonly EVENTS = {
CINE_STATE_CHANGED: 'event::cineStateChanged',
};
const serviceImplementation = {
_getState: () => console.warn('getState() NOT IMPLEMENTED'),
_setCine: () => console.warn('setCine() NOT IMPLEMENTED'),
_playClip: () => console.warn('playClip() NOT IMPLEMENTED'),
_stopClip: () => console.warn('stopClip() NOT IMPLEMENTED'),
_setIsCineEnabled: () => console.warn('setIsCineEnabled() NOT IMPLEMENTED'),
};
function _getState() {
return serviceImplementation._getState();
}
function _setCine({ id, frameRate, isPlaying }) {
return serviceImplementation._setCine({ id, frameRate, isPlaying });
}
function _setIsCineEnabled(isCineEnabled) {
return serviceImplementation._setIsCineEnabled(isCineEnabled);
}
function _playClip(element, playClipOptions) {
return serviceImplementation._playClip(element, playClipOptions);
}
function _stopClip(element) {
return serviceImplementation._stopClip(element);
}
function _onModeExit() {
_setIsCineEnabled(false);
}
function setServiceImplementation({
getState: getStateImplementation,
setCine: setCineImplementation,
setIsCineEnabled: setIsCineEnabledImplementation,
playClip: playClipImplementation,
stopClip: stopClipImplementation,
}) {
if (getStateImplementation) {
serviceImplementation._getState = getStateImplementation;
}
if (setCineImplementation) {
serviceImplementation._setCine = setCineImplementation;
}
if (setIsCineEnabledImplementation) {
serviceImplementation._setIsCineEnabled = setIsCineEnabledImplementation;
}
if (playClipImplementation) {
serviceImplementation._playClip = playClipImplementation;
}
if (stopClipImplementation) {
serviceImplementation._stopClip = stopClipImplementation;
}
}
const CineService = {
REGISTRATION: {
altName: name,
public static REGISTRATION = {
name: 'cineService',
altName: 'CineService',
create: ({ configuration = {} }) => {
return publicAPI;
return new CineService();
},
},
};
};
serviceImplementation = {};
constructor() {
super(CineService.EVENTS);
this.serviceImplementation = {};
}
public getState() {
return this.serviceImplementation._getState();
}
public setCine({ id, frameRate, isPlaying }) {
return this.serviceImplementation._setCine({ id, frameRate, isPlaying });
}
public setIsCineEnabled(isCineEnabled) {
this.serviceImplementation._setIsCineEnabled(isCineEnabled);
// Todo: for some reason i need to do this setTimeout since the
// reducer state does not get updated right away and if we publish the
// event and we use the cineService.getState() it will return the old state
setTimeout(() => {
this._broadcastEvent(this.EVENTS.CINE_STATE_CHANGED, isCineEnabled);
}, 0);
}
public playClip(element, playClipOptions) {
return this.serviceImplementation._playClip(element, playClipOptions);
}
public stopClip(element) {
return this.serviceImplementation._stopClip(element);
}
public _onModeExit() {
this.setIsCineEnabled(false);
}
public setServiceImplementation({
getState: getStateImplementation,
setCine: setCineImplementation,
setIsCineEnabled: setIsCineEnabledImplementation,
playClip: playClipImplementation,
stopClip: stopClipImplementation,
}) {
if (getStateImplementation) {
this.serviceImplementation._getState = getStateImplementation;
}
if (setCineImplementation) {
this.serviceImplementation._setCine = setCineImplementation;
}
if (setIsCineEnabledImplementation) {
this.serviceImplementation._setIsCineEnabled = setIsCineEnabledImplementation;
}
if (playClipImplementation) {
this.serviceImplementation._playClip = playClipImplementation;
}
if (stopClipImplementation) {
this.serviceImplementation._stopClip = stopClipImplementation;
}
}
}
export default CineService;
@@ -135,15 +135,15 @@ export default class CustomizationService extends PubSubService {
return this.getModeCustomization(customizationId, defaultValue);
}
/** Mode customizations are changes to the behaviour of the extensions
/** Mode customizations are changes to the behavior 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.
* @param defaultValue to return if no customization specified.
*/
public getModeCustomization(
customizationId: string,
defaultValue?: Customization
): Customization | void {
): Customization {
const customization =
this.globalCustomizations[customizationId] ??
this.modeCustomizations[customizationId] ??
@@ -113,7 +113,7 @@ class MeasurementService extends PubSubService {
public readonly VALUE_TYPES = VALUE_TYPES;
private measurements = new Map();
private unmappedMeasurements = new Set();
private unmappedMeasurements = new Map();
constructor() {
super(EVENTS);
@@ -485,7 +485,15 @@ class MeasurementService extends PubSubService {
measurement = toMeasurementSchema(sourceAnnotationDetail);
measurement.source = source;
} catch (error) {
this.unmappedMeasurements.add(sourceAnnotationDetail.uid);
// Todo: handle other
this.unmappedMeasurements.set(sourceAnnotationDetail.uid, {
...sourceAnnotationDetail,
source: {
name: source.name,
version: source.version,
uid: source.uid,
},
});
console.log('Failed to map', error);
throw new Error(
@@ -567,10 +575,9 @@ class MeasurementService extends PubSubService {
}
clearMeasurements() {
this.unmappedMeasurements.clear();
// Make a copy of the measurements
const measurements = [...this.measurements.values()];
const measurements = [...this.measurements.values(), ...this.unmappedMeasurements.values()];
this.unmappedMeasurements.clear();
this.measurements.clear();
this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED, { measurements });
}
@@ -1,9 +1,13 @@
import log from './../log.js';
import Services from '../types/Services';
import CommandsManager from '../classes/CommandsManager';
import ExtensionManager from '../extensions/ExtensionManager';
export default class ServicesManager {
public services: Services = {};
public registeredServiceNames: string[] = [];
private _commandsManager: CommandsManager;
private _extensionManager: ExtensionManager;
constructor(commandsManager: CommandsManager) {
this._commandsManager = commandsManager;
@@ -11,6 +15,10 @@ export default class ServicesManager {
this.registeredServiceNames = [];
}
setExtensionManager(extensionManager) {
this._extensionManager = extensionManager;
}
/**
* Registers a new service.
*
@@ -40,6 +48,7 @@ export default class ServicesManager {
configuration,
commandsManager: this._commandsManager,
servicesManager: this,
extensionManager: this._extensionManager,
});
if (service.altName) {
console.log('Registering old name', service.altName);
@@ -1,109 +1,79 @@
import merge from 'lodash.merge';
import { CommandsManager } from '../../classes';
import { ExtensionManager } from '../../extensions';
import { PubSubService } from '../_shared/pubSubServiceInterface';
import type { RunCommand, Commands } from '../../types/Command';
import type { RunCommand } from '../../types/Command';
import ServicesManager from '../ServicesManager';
import { Button, ButtonProps, EvaluateFunction, EvaluatePublic, NestedButtonProps } from './types';
const EVENTS = {
TOOL_BAR_MODIFIED: 'event::toolBarService:toolBarModified',
TOOL_BAR_STATE_MODIFIED: 'event::toolBarService:toolBarStateModified',
};
export type ButtonListeners = Record<string, RunCommand>;
export interface ButtonProps {
primary?: Button;
secondary?: Button;
items?: Button[];
}
export interface Button extends Commands {
id: string;
icon?: string;
label?: string;
type?: string;
tooltip?: string;
isActive?: boolean;
listeners?: ButtonListeners;
props?: ButtonProps;
}
export interface ExtraButtonOptions {
listeners?: ButtonListeners;
isActive?: boolean;
}
export default class ToolbarService extends PubSubService {
public static REGISTRATION = {
name: 'toolbarService',
// Note the old name is ToolBarService, with an upper B
altName: 'ToolBarService',
create: ({ commandsManager }) => {
return new ToolbarService(commandsManager);
create: ({ commandsManager, extensionManager, servicesManager }) => {
return new ToolbarService(commandsManager, extensionManager, servicesManager);
},
};
public static _createButton(
type: string,
id: string,
icon: string,
label: string,
commands: Command | Commands,
tooltip?: string,
extraOptions?: ExtraButtonOptions
): Button {
public static createButton(options: {
id: string;
label: string;
commands: RunCommand;
icon?: string;
tooltip?: string;
evaluate?: EvaluatePublic;
listeners?: Record<string, RunCommand>;
}): ButtonProps {
const { id, icon, label, commands, tooltip, evaluate, listeners } = options;
return {
id,
icon,
label,
type,
commands,
tooltip,
...extraOptions,
tooltip: tooltip || label,
evaluate,
listeners,
};
}
public static _createActionButton = ToolbarService._createButton.bind(null, 'action');
public static _createToggleButton = ToolbarService._createButton.bind(null, 'toggle');
public static _createToolButton = ToolbarService._createButton.bind(null, 'tool');
buttons: Record<string, Button> = {};
state: {
primaryToolId: string;
toggles: Record<string, boolean>;
groups: Record<string, unknown>;
} = { primaryToolId: '', toggles: {}, groups: {} };
buttonSections: Record<string, unknown> = {
/**
* primary: ['Zoom', 'Wwwc'],
* secondary: ['Length', 'RectangleRoi']
*/
// all buttons in the toolbar with their props
buttons: Record<string, Button>;
// the buttons in the toolbar, grouped by section, with their ids
buttonSections: Record<string, string[]>;
} = {
buttons: {},
buttonSections: {},
};
_commandsManager: CommandsManager;
extensionManager: ExtensionManager;
_extensionManager: ExtensionManager;
_servicesManager: ServicesManager;
_evaluateFunction: Record<string, EvaluateFunction> = {};
_serviceSubscriptions = [];
defaultTool: Record<string, unknown>;
constructor(commandsManager: CommandsManager) {
constructor(
commandsManager: CommandsManager,
extensionManager: ExtensionManager,
servicesManager: ServicesManager
) {
super(EVENTS);
this._commandsManager = commandsManager;
}
public init(extensionManager: ExtensionManager): void {
this.extensionManager = extensionManager;
this._extensionManager = extensionManager;
this._servicesManager = servicesManager;
}
public reset(): void {
this.unsubscriptions.forEach(unsub => unsub());
this.state = {
primaryToolId: 'WindowLevel',
toggles: {},
groups: {},
buttons: {},
buttonSections: {},
};
this.unsubscriptions = [];
this.buttonSections = {};
this.buttons = {};
}
public onModeEnter(): void {
@@ -111,16 +81,52 @@ export default class ToolbarService extends PubSubService {
}
/**
* Sets the default tool that will be activated whenever the primary tool is
* deactivated without activating another/different tool.
* @param interaction the interaction command that will set the default tool active
* Registers an evaluate function with the specified name.
*
* @param name - The name of the evaluate function.
* @param handler - The evaluate function handler.
*/
public setDefaultTool(interaction) {
this.defaultTool = interaction;
public registerEvaluateFunction(name: string, handler: EvaluateFunction) {
this._evaluateFunction[name] = handler;
}
public getDefaultTool() {
return this.defaultTool;
/**
* Registers a service and its event to listen for updates and refreshes the toolbar state when the event is triggered.
* @param service - The service to register.
* @param event - The event to listen for.
*/
public registerEventForToolbarUpdate(service, events) {
const { viewportGridService } = this._servicesManager.services;
const callback = () => {
const viewportId = viewportGridService.getActiveViewportId();
this.refreshToolbarState({ viewportId });
};
const unsubscriptions = events.map(event => {
if (service.subscribe) {
return service.subscribe(event, callback);
} else if (service.addEventListener) {
return service.addEventListener(event, callback);
}
});
unsubscriptions.forEach(unsub => this._serviceSubscriptions.push(unsub));
}
/**
* Adds buttons to the toolbar.
* @param buttons - The buttons to be added.
*/
public addButtons(buttons: Button[]): void {
buttons.forEach(button => {
if (!this.state.buttons[button.id]) {
this.state.buttons[button.id] = button;
}
});
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {
...this.state,
});
}
/**
@@ -130,160 +136,180 @@ export default class ToolbarService extends PubSubService {
* used for calling the specified interaction. That is, the command is
* called with {...commandOptions,...options}
*/
public recordInteraction(interaction, options?: Record<string, unknown>) {
if (!interaction) {
return;
public recordInteraction(
interaction,
options?: {
refreshProps: Record<string, unknown>;
[key: string]: unknown;
}
const commandsManager = this._commandsManager;
const { groupId, itemId, commands, type } = interaction;
let { interactionType } = interaction;
// if not interaction type, assume the type can be used
if (!interactionType) {
interactionType = type;
) {
// if interaction is a string, we can assume it is the itemId
// and get the props to get the other properties
if (typeof interaction === 'string') {
interaction = this.getButtonProps(interaction);
}
switch (interactionType) {
case 'action': {
commandsManager.run(commands, options);
break;
}
case 'tool': {
try {
const alternateInteraction =
this.state.primaryToolId === itemId &&
this.defaultTool?.itemId !== itemId &&
this.getDefaultTool();
if (alternateInteraction) {
// Allow toggling the mode off
return this.recordInteraction(alternateInteraction, options);
}
commandsManager.run(commands, options);
const itemId = interaction.itemId ?? interaction.id;
interaction.itemId = itemId;
// only set the primary tool if no error was thrown.
// if the itemId is not undefined use it; otherwise, set the first tool in
// the commands as the primary tool
this.state.primaryToolId = itemId || commands[0].commandOptions?.toolName;
} catch (error) {
console.warn(error);
}
const commands = Array.isArray(interaction.commands)
? interaction.commands
: [interaction.commands];
break;
}
case 'toggle': {
const { commands } = interaction;
let commandExecuted;
// only toggle if a command was executed
this.state.toggles[itemId] =
this.state.toggles[itemId] === undefined ? true : !this.state.toggles[itemId];
if (!commands) {
break;
}
commands.forEach(({ commandName, commandOptions, context }) => {
if (!commandOptions) {
commandOptions = {};
}
if (commandName) {
commandOptions.toggledState = this.state.toggles[itemId];
try {
commandsManager.runCommand(commandName, commandOptions, context);
commandExecuted = true;
} catch (error) {
console.warn(error);
}
}
});
if (!commandExecuted) {
// If no command was executed, we need to toggle the state back
this.state.toggles[itemId] = !this.state.toggles[itemId];
}
break;
}
default:
throw new Error(`Invalid interaction type: ${interactionType}`);
}
// Todo: comment out for now
// Run command if there's one associated
//
// NOTE: Should probably just do this for tools as well?
// But would be nice if we could enforce at least the command name?
// let unsubscribe;
// if (commandName) {
// unsubscribe = commandsManager.runCommand(commandName, commandOptions);
// }
// // Storing the unsubscribe for later resetting
// if (unsubscribe && typeof unsubscribe === 'function') {
// if (this.unsubscriptions.indexOf(unsubscribe) === -1) {
// this.unsubscriptions.push(unsubscribe);
// }
// }
// Track last touched id for each group
if (groupId) {
this.state.groups[groupId] = itemId;
}
this._broadcastEvent(this.EVENTS.TOOL_BAR_STATE_MODIFIED, { ...this.state });
}
getButtons() {
return this.buttons;
}
getActiveTools() {
const activeTools = [this.state.primaryToolId];
Object.keys(this.state.toggles).forEach(key => {
if (this.state.toggles[key]) {
activeTools.push(key);
}
});
return activeTools;
}
getActivePrimaryTool() {
return this.state.primaryToolId;
}
/** Sets the toggle state of a button to the isToggled state */
public setToggled(id: string, isToggled: boolean): void {
if (isToggled) {
this.state.toggles[id] = true;
} else {
delete this.state.toggles[id];
}
}
setButton(id, button) {
if (this.buttons[id]) {
this.buttons[id] = merge(this.buttons[id], button);
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {
buttons: this.buttons,
button: this.buttons[id],
buttonSections: this.buttonSections,
if (!commands?.length) {
this.refreshToolbarState({
...options?.refreshProps,
itemId,
interaction,
});
}
const commandOptions = { ...options, ...interaction };
// Loop through commands and run them with the combined options
this._commandsManager.run(commands, commandOptions);
this.refreshToolbarState({
...options?.refreshProps,
itemId,
interaction,
});
}
/**
* Consolidates the state of the toolbar after an interaction, it accepts
* props that get passed to the buttons
*
* @param refreshProps - The props that buttons need to get evaluated, they can be
* { viewportId, toolGroup} for cornerstoneTools.
*
* Todo: right now refreshToolbarState should be used in the context where
* we have access to the toolGroup and viewportId, but we should be able to
* pass the props to the toolbar service and it should be able to decide
* which buttons to evaluate based on the props
*/
public refreshToolbarState(refreshProps) {
const buttons = this.state.buttons;
// Tracks evaluated buttons to avoid re-evaluating them (this will
// cause issue for toggles where if the button is in primary
// and secondary it will be evaluated twice)
const evaluationResults = new Map();
const evaluateButtonProps = (button, props, refreshProps) => {
if (evaluationResults.has(button.id)) {
const { disabled, className, isActive } = evaluationResults.get(button.id);
return { ...props, disabled, className, isActive };
} else {
const evaluated = props.evaluate?.({ ...refreshProps, button });
const updatedProps = {
...props,
disabled: evaluated?.disabled || false,
className: evaluated?.className || '',
isActive: evaluated?.isActive, // isActive will be undefined for buttons without this prop
};
evaluationResults.set(button.id, updatedProps);
return updatedProps;
}
};
const refreshedButtons = Object.values(buttons).reduce((acc, button: Button) => {
const isNested = (button.props as NestedButtonProps)?.groupId;
if (!isNested) {
this.handleEvaluate(button.props);
const buttonProps = button.props as ButtonProps;
const updatedProps = evaluateButtonProps(button, buttonProps, refreshProps);
acc[button.id] = {
...button,
props: updatedProps,
};
} else {
let buttonProps = button.props as NestedButtonProps;
// if it is nested we should perform evaluate on each item in the group
this.handleEvaluateNested(buttonProps);
const { evaluate: groupEvaluate } = buttonProps;
const groupEvaluated = groupEvaluate?.({ ...refreshProps, button });
// handle group evaluate function which might switch the primary
// item in the group
buttonProps = {
...buttonProps,
primary: groupEvaluated?.primary || buttonProps.primary,
};
const { primary, items } = buttonProps;
// primary and items evaluate functions
let updatedPrimary;
if (primary) {
updatedPrimary = evaluateButtonProps(primary, primary, refreshProps);
}
const updatedItems = items.map(item => evaluateButtonProps(item, item, refreshProps));
buttonProps = {
...buttonProps,
primary: updatedPrimary,
items: updatedItems,
};
acc[button.id] = {
...button,
props: buttonProps,
};
}
return acc;
}, {});
this.setButtons(refreshedButtons);
return this.state;
}
/**
* Sets the buttons for the toolbar, don't use this method to record an
* interaction, since it doesn't update the state of the buttons, use
* this if you know the buttons you want to set and you want to set them
* all at once.
* @param buttons - The buttons to set.
*/
public setButtons(buttons) {
this.state.buttons = buttons;
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {
buttons: this.state.buttons,
buttonSections: this.state.buttonSections,
});
}
/**
* Retrieves a button by its ID.
* @param id - The ID of the button to retrieve.
* @returns The button with the specified ID.
*/
public getButton(id: string): Button {
return this.buttons[id];
return this.state.buttons[id];
}
/** Gets a nested button, found in the items/props for the children */
public getNestedButton(id: string): Button {
if (this.buttons[id]) {
return this.buttons[id];
}
for (const buttonId of Object.keys(this.buttons)) {
const { primary, items } = this.buttons[buttonId].props || {};
/**
* Retrieves the buttons from the toolbar service.
* @returns An array of buttons.
*/
public getButtons() {
return this.state.buttons;
}
/**
* Retrieves the button properties for the specified button ID.
* It prioritizes nested buttons over regular buttons if the ID is found
* in both.
*
* @param id - The ID of the button.
* @returns The button properties.
*/
public getButtonProps(id: string): ButtonProps {
for (const buttonId of Object.keys(this.state.buttons)) {
const { primary, items } = (this.state.buttons[buttonId].props as NestedButtonProps) || {};
if (primary?.id === id) {
return primary;
}
@@ -292,97 +318,57 @@ export default class ToolbarService extends PubSubService {
return found;
}
}
// This should be checked after we checked the nested buttons, since
// we are checking based on the ids, the nested objects are higher priority
// and more specific
if (this.state.buttons[id]) {
return this.state.buttons[id].props as ButtonProps;
}
}
setButtons(buttons) {
this.buttons = buttons;
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {
buttons: this.buttons,
buttonSections: this.buttonSections,
});
}
_getButtonUITypes() {
const registeredToolbarModules = this._extensionManager.modules['toolbarModule'];
_buttonTypes() {
const buttonTypes = {};
const registeredToolbarModules = this.extensionManager.modules['toolbarModule'];
if (Array.isArray(registeredToolbarModules) && registeredToolbarModules.length) {
registeredToolbarModules.forEach(toolbarModule =>
toolbarModule.module.forEach(def => {
buttonTypes[def.name] = def;
})
);
if (!Array.isArray(registeredToolbarModules)) {
return {};
}
return buttonTypes;
}
createButtonSection(key, buttons) {
// Maybe do this mapping at time of return, instead of time of create
// Props check important for validation here...
this.buttonSections[key] = buttons;
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {});
}
/**
*
* Finds a button section by it's name/tool group id, then maps the list of string name
* identifiers to schema/values that can be used to render the buttons.
*
* @param toolGroupId - the tool group id
* @param props - optional properties to apply to every button of the section
* @param defaultToolGroupId - the fallback section to return if the given toolGroupId section is not available
*/
getButtonSection(
toolGroupId: string,
props?: Record<string, unknown>,
defaultToolGroupId = 'primary'
) {
const buttonSectionIds =
this.buttonSections[toolGroupId] || this.buttonSections[defaultToolGroupId];
const buttonsInSection = [];
if (buttonSectionIds && buttonSectionIds.length !== 0) {
buttonSectionIds.forEach(btnId => {
const btn = this.buttons[btnId];
const metadata = {};
const mappedBtn = this._mapButtonToDisplay(btn, toolGroupId, metadata, props);
buttonsInSection.push(mappedBtn);
return registeredToolbarModules.reduce((buttonTypes, toolbarModule) => {
toolbarModule.module.forEach(def => {
buttonTypes[def.name] = def;
});
}
return buttonsInSection;
return buttonTypes;
}, {});
}
/**
*
* @param {object[]} buttons
* @param {string} buttons[].id
* Creates a button section with the specified key and buttons.
* @param {string} key - The key of the button section.
* @param {Array} buttons - The buttons to be added to the section.
*/
addButtons(buttons) {
buttons.forEach(button => {
if (!this.buttons[button.id]) {
this.buttons[button.id] = button;
}
});
this._setTogglesForButtonItems(buttons);
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {});
createButtonSection(key, buttons) {
this.state.buttonSections[key] = buttons;
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { ...this.state });
}
_setTogglesForButtonItems(buttons) {
if (!buttons) {
return;
}
/**
* Retrieves the button section with the specified sectionId.
*
* @param sectionId - The ID of the button section to retrieve.
* @param props - Optional additional properties for mapping the button to display.
* @returns An array of buttons in the specified section, mapped to their display representation.
*/
getButtonSection(sectionId: string, props?: Record<string, unknown>) {
const buttonSectionIds = this.state.buttonSections[sectionId];
buttons.forEach(buttonItem => {
if (buttonItem.type === 'toggle') {
this.setToggled(buttonItem.id, buttonItem.isActive);
}
this._setTogglesForButtonItems(buttonItem.props?.items);
});
return (
buttonSectionIds?.map(btnId => {
const btn = this.state.buttons[btnId];
return this._mapButtonToDisplay(btn, props);
}) || []
);
}
/**
@@ -392,18 +378,22 @@ export default class ToolbarService extends PubSubService {
* @param {*} metadata
* @param {*} props - Props set by the Viewer layer
*/
_mapButtonToDisplay(btn, btnSection, metadata, props) {
_mapButtonToDisplay(btn, props) {
if (!btn) {
return;
}
const { id, type, component } = btn;
const buttonType = this._buttonTypes()[type];
const { id, uiType, component } = btn;
const { groupId } = btn.props;
const buttonType = this._getButtonUITypes()[uiType];
if (!buttonType) {
return;
}
!groupId ? this.handleEvaluate(btn.props) : this.handleEvaluateNested(btn.props);
return {
id,
Component: component || buttonType.defaultComponent,
@@ -411,7 +401,54 @@ export default class ToolbarService extends PubSubService {
};
}
handleEvaluateNested = props => {
const { primary, items } = props;
// handle group evaluate function
this.handleEvaluate(props);
// primary and items evaluate functions
if (primary) {
this.handleEvaluate(primary);
}
items.forEach(item => this.handleEvaluate(item));
};
handleEvaluate = props => {
const { evaluate } = props;
if (typeof evaluate === 'function') {
return;
}
if (typeof evaluate === 'string') {
const evaluateFunction = this._evaluateFunction[evaluate];
if (evaluateFunction) {
props.evaluate = evaluateFunction;
return;
}
throw new Error(
`Evaluate function not found for name: ${evaluate}, you can register an evaluate function with the getToolbarModule in your extensions`
);
}
if (typeof evaluate === 'object') {
const { name, options } = evaluate;
const evaluateFunction = this._evaluateFunction[name];
if (evaluateFunction) {
props.evaluate = args => evaluateFunction({ ...args, ...options });
return;
}
throw new Error(
`Evaluate function not found for name: ${name}, you can register an evaluate function with the getToolbarModule in your extensions`
);
}
};
getButtonComponentForUIType(uiType: string) {
return uiType ? this._buttonTypes()[uiType]?.defaultComponent ?? null : null;
return uiType ? this._getButtonUITypes()[uiType]?.defaultComponent ?? null : null;
}
}
@@ -0,0 +1,43 @@
import type { RunCommand } from '../../types/Command';
export type EvaluatePublic = string | EvaluateFunction;
export type EvaluateFunction = (props: Record<string, unknown>) => {
disabled: boolean;
className: string;
};
export type ButtonProps = {
id: string;
icon: string;
label: string;
tooltip?: string;
commands?: RunCommand;
disabled?: boolean;
className?: string;
evaluate?: EvaluatePublic;
listeners?: Record<string, RunCommand>;
};
export type NestedButtonProps = {
groupId: string;
// group evaluate which is different
// from the evaluate function for the primary and items
evaluate?: EvaluatePublic;
items: ButtonProps[];
primary: ButtonProps & {
// Todo: this is really ugly but really we don't have any other option
// the ui design requires this since the button should be rounded if
// active otherwise it should not be rounded
isActive?: boolean;
};
secondary: ButtonProps;
};
export type Button = {
id: string;
props: ButtonProps | NestedButtonProps;
// button ui type (e.g. 'ohif.splitButton', 'ohif.radioGroup')
// extensions can provide custom components for these types
uiType: string;
};
@@ -7,6 +7,7 @@ class ViewportGridService extends PubSubService {
LAYOUT_CHANGED: 'event::layoutChanged',
GRID_STATE_CHANGED: 'event::gridStateChanged',
GRID_SIZE_CHANGED: 'event::gridSizeChanged',
VIEWPORTS_READY: 'event::viewportsReady',
};
public static REGISTRATION = {
@@ -35,6 +36,7 @@ class ViewportGridService extends PubSubService {
onModeExit: onModeExitImplementation,
set: setImplementation,
getNumViewportPanes: getNumViewportPanesImplementation,
setViewportIsReady: setViewportIsReadyImplementation,
}): void {
if (getStateImplementation) {
this.serviceImplementation._getState = getStateImplementation;
@@ -61,6 +63,14 @@ class ViewportGridService extends PubSubService {
if (getNumViewportPanesImplementation) {
this.serviceImplementation._getNumViewportPanes = getNumViewportPanesImplementation;
}
if (setViewportIsReadyImplementation) {
this.serviceImplementation._setViewportIsReady = setViewportIsReadyImplementation;
}
}
public publishViewportsReady() {
this._broadcastEvent(this.EVENTS.VIEWPORTS_READY, {});
}
public setActiveViewportId(id: string) {
@@ -74,6 +84,10 @@ class ViewportGridService extends PubSubService {
return this.serviceImplementation._getState();
}
public setViewportIsReady(viewportId, callback) {
this.serviceImplementation._setViewportIsReady(viewportId, callback);
}
public getActiveViewportId() {
const state = this.getState();
return state.activeViewportId;
@@ -110,6 +124,17 @@ class ViewportGridService extends PubSubService {
});
}
/**
* Retrieves the display set instance UIDs for a given viewport.
* @param viewportId The ID of the viewport.
* @returns An array of display set instance UIDs.
*/
public getDisplaySetsUIDsForViewport(viewportId: string) {
const state = this.getState();
const viewport = state.viewports.get(viewportId);
return viewport?.displaySetInstanceUIDs;
}
/**
*
* @param numCols, numRows - the number of columns and rows to apply
+2 -2
View File
@@ -13,11 +13,11 @@ import HangingProtocolService from './HangingProtocolService';
import pubSubServiceInterface, { PubSubService } from './_shared/pubSubServiceInterface';
import UserAuthenticationService from './UserAuthenticationService';
import CustomizationService from './CustomizationService';
import Services from '../types/Services';
import StateSyncService from './StateSyncService';
import PanelService from './PanelService';
import type Services from '../types/Services';
export {
Services,
MeasurementService,
+3 -1
View File
@@ -1,9 +1,11 @@
export interface Command {
export type SimpleCommand = string;
export interface ComplexCommand {
commandName: string;
commandOptions?: Record<string, unknown>;
context?: string;
}
export type Command = SimpleCommand | ComplexCommand;
export type RunCommand = Command | Command[];
/** A set of commands, typically contained in a tool item or other configuration */
Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

-1
View File
@@ -649,7 +649,6 @@ onModeEnter: ({ servicesManager, extensionManager, commandsManager }) => {
// Init tool groups (see cornerstone3D for more details)
initToolGroups(extensionManager, toolGroupService, commandsManager);
toolbarService.init(extensionManager);
toolbarService.addButtons(toolbarButtons);
toolbarService.createButtonSection('primary', [
'MeasurementTools',
@@ -6,29 +6,24 @@ sidebar_label: Toolbar
# Module: Toolbar
An extension can register a Toolbar Module by defining a `getToolbarModule`
method. `OHIF-v3`'s `default` extension (`"@ohif/extension-default"`) provides 5 main
toolbar button types:
method. `OHIF-v3`'s `default` extension (`"@ohif/extension-default"`) provides the
following toolbar button `uiTypes`:
![toolbarModule](../../../assets/img/toolbar-module.png)
- `ohif.radioGroup`: which is a simple button that can be clicked
- `ohif.splitButton`: which is a button with a dropdown menu
- `ohif.divider`: which is a simple divider
## Example Toolbar Module
The Toolbar Module should return an array of `objects`. There are currently a
few different variations of definitions, each one is detailed further down.
There are two things that the toolbar module can provide, first
a component, and second evaluators.
### Components
```js
export default function getToolbarModule({ commandsManager, servicesManager }) {
return [
{
name: 'ohif.divider',
defaultComponent: ToolbarDivider,
clickHandler: () => {},
},
{
name: 'ohif.action',
defaultComponent: ToolbarButton,
clickHandler: () => {},
},
{
name: 'ohif.radioGroup',
defaultComponent: ToolbarButton,
@@ -53,214 +48,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) {
}
```
## Toolbar buttons consumed in modes
Below we can see a simplified version of the `longitudinal` mode that shows how
a mode can add buttons to the toolbar by calling
`ToolBarService.addButtons(toolbarButtons)`. `toolbarButtons` is an array of
`toolDefinitions` which we will learn next.
```js
function modeFactory({ modeConfiguration }) {
return {
id: 'viewer',
displayName: 'Basic Viewer',
onModeEnter: ({ servicesManager, extensionManager }) => {
const { ToolBarService } = servicesManager.services;
ToolBarService.init(extensionManager);
ToolBarService.addButtons(toolbarButtons);
},
routes: [
{
path: 'longitudinal',
layoutTemplate: ({ location, servicesManager }) => {
return {
/* */
};
},
},
],
};
}
```
## Button Definitions
The simplest toolbarButtons definition has the following properties:
![toolbarModule-zoom](../../../assets/img/toolbarModule-zoom.png)
```js
{
"id": "Zoom",
"type": "ohif.radioGroup",
"props": {
"type": "tool",
"icon": "tool-zoom",
"label": "Zoom",
"commands": [
{
"commandName": "setToolActive",
"commandOptions": {
"toolName": "Zoom"
},
"context": "CORNERSTONE"
}
]
}
}
```
| property | description | values |
| ---------------- | ----------------------------------------------------------------- | ------------------------------------------- |
| `id` | Unique string identifier for the definition | \* |
| `type` | Used to determine the button's behaviour | "tool", "toggle", "action" |
| `icon` | A string name for an icon supported by the consuming application. | \* |
| `label` | User/display friendly to show in UI | \* |
| `commands` | (optional) The commands to run when the button is used. It include a commandName, commandOptions, and/or a context | Any command registered by a `CommandModule` |
There are three main types of toolbar buttons:
- `tool`: buttons that enable a tool by running the `setToolActive` command with
the `commandOptions`
- `toggle`: buttons that acts as a toggle: e.g., linking viewports
- `action`: buttons that executes an action: e.g., capture button to save
screenshot
## Nested Buttons
You can use the `ohif.splitButton` type to build a button with extra tools in
the dropdown.
- First you need to give your `primary` tool definition to the split button. The primary
tool can specify a `uiType` property which can be one of the button types returned by
`getToolbarModule` that is a variation of `ToolbarButton`. If `uiType` is omitted then
`ToolbarButton` is used by default.
- The `secondary` properties can be a simple arrow down (`chevron-down` icon)
- For adding the extra tools add them to the `items` list.
You can see below how `longitudinal` mode is using the available toolbarModule
to create `MeasurementTools` nested button
![toolbarModule-nested-buttons](../../../assets/img/toolbarModule-nested-buttons.png)
```js title="modes/longitudinal/src/toolbarButtons.js"
{
"id": "MeasurementTools",
"type": "ohif.splitButton",
"props": {
"groupId": "MeasurementTools",
"isRadio": true,
"primary": {
"id": "Length",
"icon": "tool-length",
"label": "Length",
"type": "tool",
"commands": [
{
"commandName": "setToolActive",
"commandOptions": {
"toolName": "Length"
},
"context": "CORNERSTONE"
},
{
"commandName": "setToolActive",
"commandOptions": {
"toolName": "SRLength",
"toolGroupId": "SRToolGroup"
},
"context": "CORNERSTONE"
}
],
"tooltip": "Length"
},
"secondary": {
"icon": "chevron-down",
"label": "",
"isActive": true,
"tooltip": "More Measure Tools"
},
"items": [
{
"id": "Bidirectional",
"icon": "tool-bidirectional",
"label": "Bidirectional",
"type": "tool",
"commands": [
{
"commandName": "setToolActive",
"commandOptions": {
"toolName": "Bidirectional"
},
"context": "CORNERSTONE"
},
{
"commandName": "setToolActive",
"commandOptions": {
"toolName": "SRBidirectional",
"toolGroupId": "SRToolGroup"
},
"context": "CORNERSTONE"
}
],
"tooltip": "Bidirectional Tool"
},
{
"id": "ArrowAnnotate",
"icon": "tool-annotate",
"label": "Annotation",
"type": "tool",
"commands": [
{
"commandName": "setToolActive",
"commandOptions": {
"toolName": "ArrowAnnotate"
},
"context": "CORNERSTONE"
},
{
"commandName": "setToolActive",
"commandOptions": {
"toolName": "SRArrowAnnotate",
"toolGroupId": "SRToolGroup"
},
"context": "CORNERSTONE"
}
],
"tooltip": "Arrow Annotate"
},
]
}
}
```
<div style={{padding:"56.25% 0 0 0", position:"relative"}}>
<iframe src="https://player.vimeo.com/video/547957214?badge=0&amp;autopause=0&amp;player_id=0&amp;app_id=58479" allow="autoplay; fullscreen; picture-in-picture" allowFullScreen style= {{ position:"absolute",top:0,left:0,width:"100%",height:"100%"}} title="measurement-report"></iframe>
</div>
## Layout Template
Layout selector button and logic is also provided by the OHIF-v3 `default`
extension. To use it, you can just add the following definition to the list of
`toolDefinitions`
![toolbarModule-layout](../../../assets/img/toolbarModule-layout.png)
```js
{
id: 'Layout',
type: 'ohif.layoutSelector',
}
```
<div style={{padding:"56.25% 0 0 0", position:"relative"}}>
<iframe src="https://player.vimeo.com/video/545993263?badge=0&amp;autopause=0&amp;player_id=0&amp;app_id=58479" allow="autoplay; fullscreen; picture-in-picture" allowFullScreen style= {{ position:"absolute",top:0,left:0,width:"100%",height:"100%"}} title="measurement-report"></iframe>
</div>
## Custom Button
### Custom Components
You can also create your own extension, and add your new custom tool appearance
(e.g., split horizontally instead of vertically for split tool). Simply add
@@ -283,6 +71,323 @@ export default function getToolbarModule({ commandsManager, servicesManager }) {
}
```
## Custom tool
Check out how to assemble the toolbar in the [modes](../../modes/index.md) section.
**I want to create a new tool**
### Evaluators
Buttons may be equipped with evaluators, which are functions invoked by the toolbarService to assess the button's status. These evaluators are expected to return an object of `{className}` and may include additional details, as elaborated in the subsequent section.
Evaluators play a crucial role in determining the button's status based on the viewport. For example, users should be restricted from clicking on the mpr if the displaySet is not reconstructable. Additionally, certain buttons within the toolbar may be associated with specific toolGroups and should remain inactive for certain viewports.
Let's look at one of the evaluators (for `evaluate.cornerstoneTool`)
```js
{
name: 'evaluate.cornerstoneTool',
evaluate: ({ viewportId, button }) => {
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
if (!toolGroup) {
return;
}
const toolName = getToolNameForButton(button);
if (!toolGroup || !toolGroup.hasTool(toolName)) {
return {
disabled: true,
className: '!text-common-bright ohif-disabled',
};
}
const isPrimaryActive = toolGroup.getActivePrimaryMouseButtonTool() === toolName;
return {
disabled: false,
className: isPrimaryActive
? '!text-black bg-primary-light'
: '!text-common-bright hover:!bg-primary-dark hover:!text-primary-light',
};
},
},
```
as you can see the job of this evaluator is to determine if the button should be disabled or not. It does so by checking the `toolGroup` and the `toolName` and then returns an object with `disabled` and `className` properties.
The following evaluators are provided by us:
- `evaluate.cornerstoneTool`: If assigned to a button (see next), it will make the button react to the active viewport state based on its toolGroup.
- `evaluate.cornerstoneTool.toggle`: It is designed to consider tools with toggle behavior, such as reference lines and image overlay (either on or off).
- `evaluate.cornerstone.synchronizer`: This is designed to consider the synchronizer state of the viewport, whether it is synced or not.
- `evaluate.viewportProperties.toggle`: Some properties of the viewport are toggleable, such as invert, flip, rotate, etc. By assigning this evaluator to those buttons, they will react to the active viewport state based on its properties. This allows for dynamic buttons that change their appearance based on the active viewport state.
- `evaluate.mpr`: special evaluator for MPR since it needs to check if the displaySet is reconstructable or not.
Sometime you want to use the same `evaluator` for different purposes, in that case you can use an object
with `name` and `options` properties. For example, in `'evaluate.cornerstone.segmentation'` we use
this pattern, where multiple toolbar buttons are using the same evaluator but with different options (
in this case `toolNames`
)
```js
{
name: 'evaluate.cornerstone.segmentation',
options: {
toolNames: ['CircleBrush' , 'SphereBrush']
},
},
```
#### Group evaluators
Split buttons (see in [ToolbarService](../../services/data/ToolbarService.md) on how to define one) may feature a group evaluator, we provide two of them and you can write your own.
- `evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList`: determine the outcome of user interactions with the split buttons on what button should be promoted to the primary section. In the example above, the cornerstone tool's status is checked, and if it is not active in the list of buttons, the button is promoted to the primary section.
- `evaluate.group.promoteToPrimary`: disregarding the cornerstone tool's status and promoting the button to the primary section regardless.
Failure to specify a group evaluator will result in no action, leaving the button in the secondary section.
:::note
As you have learned so far, the extension modules only 'provides' the functionality
and it is the mode's job to consume it. You can next learn how to consume these components
and evaluators to build a toolbar in the
:::
#### Custom Evaluators
You can create your own evaluators. For instance, you have the option to design tri-state buttons, which are buttons with three states such as Show All, Show Some, or Show None of the Viewport Overlays.
## Toolbar buttons consumed in modes
Providing just the components is not enough. You need to add the buttons to the toolbar service and decide which ones are used for each section.
Below we can see a simplified version of the `longitudinal` (basic viewer) mode that shows how
a mode can add buttons to the toolbar by calling
`ToolBarService.addButtons(toolbarButtons)`. `toolbarButtons` is an array of
`toolDefinitions` which we will learn next.
```js
function modeFactory({ modeConfiguration }) {
return {
id: 'viewer',
displayName: 'Basic Viewer',
onModeEnter: ({ servicesManager, extensionManager }) => {
const { toolBarService } = servicesManager.services;
toolbarService.addButtons([...toolbarButtons, ...moreTools]);
toolbarService.createButtonSection('primary', [
'MeasurementTools',
'Zoom',
'info',
'WindowLevel',
'Pan',
'Capture',
'Layout',
'MPR',
'Crosshairs',
'MoreTools',
]);
},
routes: [
{
path: 'longitudinal',
layoutTemplate: ({ location, servicesManager }) => {
return {
/* */
};
},
},
],
};
}
```
:::note
By default OHIF's default layout (`extensions/default/src/ViewerLayout/index.tsx`) which is used in all modes use a Toolbar component that creates a
`primary` section for tools. That is why we are creating a `primary` section in the example above.
Layouts are also customizable, and you can create your own layout in your extensions and provide it to your modes view `getLayoutTemplateModule` module.
By default we use `@ohif/extension-default.layoutTemplateModule.viewerLayout` to use the default layout which provides a
- Header (with logo on left, toolbar in the middle and user menu on the right)
- Left panel
- Main viewport grid area
- Right panel
:::
## Alternative Toolbar sections
In your UI component, such as panels, you have the option to include a toolbar section template.
This allows you to easily add buttons to it later on. To ensure that the buttons are added properly
to the toolbar, respond to interactions correctly, and evaluate states accurately, simply utilize the `useToolbar` hook.
This hook grants you access to the `onInteraction` function and the `toolbarButtons` array, which you can customize within your UI as needed.
```js
function myCustomPanel({servicesManager}){
const { onInteraction, toolbarButtons } = useToolbar({
servicesManager,
buttonSection: 'myCustomSectionName'
});
// map the buttons to the UI
return (
<div>
{toolbarButtons.map((button, index) => {
return (
<button
key={index}
onClick={() => onInteraction(button)}
>
{button.label}
</button>
);
})}
</div>
);
}
```
We have provided a common component for toolbar buttons called `Toolbox`.
The Toolbox component serves as a versatile and configurable container for toolbar tools within your application.
It is designed to work in conjunction with the useToolbar hook to manage tool states, handle user interactions, and memorize options
using context API.
The `Toolbox` can be easily integrated into your application UI, requiring only the necessary services (servicesManager, commandsManager) and configuration parameters (buttonSectionId, title). Here's a simple usage scenario:
```js
function MyApplication({ servicesManager, commandsManager }) {
// Configuration for the toolbox container
const config = {
servicesManager,
commandsManager,
buttonSectionId: 'customButtonSection',
title: 'My Toolbox',
};
return <Toolbox {...config} />;
}
```
Then in your modes you can edit the tools in that button section.
```js
onModeEnter: ({ servicesManager, extensionManager }) => {
const { toolBarService } = servicesManager.services;
toolbarService.addButtons([...toolbarButtons, ...moreTools]);
toolbarService.createButtonSection('customButtonSection', [
'MeasurementTools',
'Zoom',
'info',
]);
},
```
Another example might be you want to open a modal to show some tool options when a button is clicked.
You can use this pattern
```js
// ToolbarButton in mode
{
id: 'Others',
uiType: 'ohif.radioGroup',
props: {
icon: 'info-action',
label: 'Others',
commands: 'showOthersModal',
},
},
```
and inside your mode factory
```js
// adding the 'Others' button to the primary section
toolbarService.createButtonSection('primary', [
'Others', // --------> this one
]);
// adding the shapes button to the 'Other' section
toolbarService.createButtonSection('other', ['Shapes']);
```
here as you see we are using a command `showOthersModal` which is defined in the commands module.
```js
// inside commandsModule of your extension
showOthersModal: () => {
const { uiModalService } = servicesManager.services;
uiModalService.show({
content: OthersModal,
title: 'Others',
customClassName: 'w-8',
movable: true,
contentProps: {
onClose: uiModalService.hide,
servicesManager,
commandsManager,
},
containerDimensions: 'h-[125px] w-[300px]',
contentDimensions: 'h-[125px] w-[300px]',
});
},
```
as you see it is opening a modal with `OthersModal` component (below) which contains the
`Toolbox` component.
```js
// Others modal
import { Toolbox } from '@ohif/ui';
function OthersModal({ servicesManager, commandsManager }) {
return (
<div className="px-2">
<Toolbox
buttonSectionId={'other'}
commandsManager={commandsManager}
servicesManager={servicesManager}
title={'other'}
useCollapsedPanel={false}
></Toolbox>
</div>
);
}
```
The result would be a modal with a toolbox inside it when the `Others` button is clicked, and the
state will get synchronized with the toolbar service automatically.
![alt text](../../../assets/img/toolbox-modal.png)
## Change Toolbar with hanging protocols
If you want to change the toolbar based on the hanging protocol, you can do a pattern like this.
```js
const { unsubscribe } = hangingProtocolService.subscribe(
hangingProtocolService.EVENTS.PROTOCOL_CHANGED,
() => {
toolbarService.createButtonSection('primary', [
'MeasurementTools',
'Zoom',
'WindowLevel',
]);
}
);
```
@@ -1,4 +1,4 @@
{
"label": "Managers",
"position": 11
"position": 10
}
@@ -1,4 +1,4 @@
{
"label": "Modes",
"position": 10
"position": 12
}
@@ -362,6 +362,11 @@ function modeFactory() {
// exports
```
### Toolbar
## Registration
Similar to extension registration, `viewer` will look inside the `pluginConfig.json` to
@@ -56,7 +56,6 @@ function modeFactory() {
// Init Default and SR ToolGroups
initToolGroups(extensionManager, ToolGroupService);
ToolBarService.init(extensionManager);
ToolBarService.addButtons(toolbarButtons);
ToolBarService.createButtonSection('primary', [
'MeasurementTools',
@@ -1,4 +1,4 @@
{
"label": "Services",
"position": 12
"position": 11
}
@@ -0,0 +1,147 @@
---
sidebar_position: 8
sidebar_label: SyncGroup Service
---
# Sync Group Service
## Overview
The `SyncGroupService` is responsible for managing synchronization groups in the OHIF Viewer. Synchronization groups allow multiple viewports to be synchronized based on various criteria, such as camera position, window level, zoom/pan, and image slice position. This service provides a centralized way to create, update, and manage synchronization groups.
Right now, synchronization groups can be defined in the hanging protocols or manually assigning buttons.
## API
- `getSyncCreatorForType(type)`: Returns the synchronizer creator function for the specified type.
- `addSynchronizerType(type, creator)`: Adds a new synchronizer type with a custom creator function.
- `getSynchronizer(id)`: Retrieves a synchronizer by its ID.
- `getSynchronizersOfType(type)`: Retrieves an array of synchronizers of the specified type.
- `addViewportToSyncGroup(viewportId, renderingEngineId, syncGroups)`: Adds a viewport to one or more synchronization groups.
- `destroy()`: Destroys all synchronizers.
- `getSynchronizersForViewport(viewportId)`: Retrieves an array of synchronizers associated with the specified viewport.
- `removeViewportFromSyncGroup(viewportId, renderingEngineId, syncGroupId?)`: Removes a viewport from a specific synchronization group or all synchronization groups if no group ID is provided.
## Usage
### Via hanging protocols
You can set up different types of synchronization groups for your viewports. For example, in the TMTV hanging protocol (`extensions/tmtv/src/getHangingProtocolModule.js`), we can see how different synchronization groups are defined for various viewports:
```javascript
const ptAXIAL = {
viewportOptions: {
// ...
syncGroups: [
{
type: 'cameraPosition',
id: 'axialSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: true,
target: false,
options: {
syncInvertState: false,
},
},
],
},
// ...
};
```
In this example, the `ptAXIAL` viewport is part of three synchronization groups:
1. `cameraPosition` group with the ID `'axialSync'`: This group synchronizes the camera position across viewports that are both source and target.
2. `voi` (Window Level) group with the ID `'ptWLSync'`: This group synchronizes the window level settings across viewports that are both source and target.
3. `voi` group with the ID `'ptFusionWLSync'`: This group synchronizes the window level settings, but the `ptAXIAL` viewport is only a source, not a target.
:::tip
You can control the state of the synchronizer via a toolbar button after you define the synchronization group in the hanging protocol.
```js
{
id: 'SyncToggle',
uiType: 'ohif.radioGroup',
props: {
icon: 'tool-info',
label: 'toggle',
commands: {
commandName: 'toggleSynchronizer',
commandOptions: {
syncId: 'axialSync'
}
}
},
},
```
as you can see by using the `toggleSynchronizer` command you can toggle the state of the synchronizer for the specified syncId.
:::
### Manually through a button
You can create a button on the toolbar that you provice the synchronization group type,
and it applys it to all viewports.
:::note
Currently we don't have a proper way to select viewports to apply the synchronization group to. It is applied to all applicable viewports
:::
For instance look at `imageSliceSync` button in the longitudinal mode (`modes/longitudinal/src/moreTools.ts`) and how it runs a command
```js
ToolbarService.createButton({
id: 'ImageSliceSync',
icon: 'link',
label: 'Image Slice Sync',
tooltip: 'Enable position synchronization on stack viewports',
commands: [
{
commandName: 'toggleSynchronizer',
commandOptions: {
type: 'imageSlice',
},
},
],
})
```
You can create another button to toggle 'voi' synchronization. Currently we group
viewports by modality and apply the voi synchronization to all viewports of the same modality.
```js
ToolbarService.createButton({
id: 'VoiSync',
icon: 'link',
label: 'VOI Sync',
tooltip: 'Enable VOI synchronization on viewports',
commands: [
{
commandName: 'toggleSynchronizer',
commandOptions: {
type: 'voi',
},
},
],
})
```
:::tip
For your custom synchronization groups, you can create a new synchronizer type and follow the
same pattern as the existing synchronizers.
:::
@@ -0,0 +1,89 @@
---
sidebar_position: 7
sidebar_label: ToolGroup Service
---
# Tool Group Service
## Overview
The `ToolGroupService` is responsible for managing tool groups in the OHIF Viewer.
:::tip
Read more about toolGroups [here](https://www.cornerstonejs.org/docs/concepts/cornerstone-tools/toolGroups)
:::
It allows you to create, update, and manage tool groups and the tools associated with them. Tool groups are used to organize and control the behavior of various tools in the viewer, such as window level, pan, zoom, measurements, and annotations.
## Events
The `ToolGroupService` emits the following events:
| Event | Description |
| ---------------------------------- | ----------------------------------------------- |
| `VIEWPORT_ADDED` | Fires when a viewport is added to a tool group |
| `TOOLGROUP_CREATED` | Fires when a new tool group is created |
## API
- `getToolGroup(toolGroupId?)`: Retrieves a tool group by its ID. If no ID is provided, it returns the tool group for the active viewport.
- `getToolGroupIds()`: Returns an array of all tool group IDs.
- `getToolGroupForViewport(viewportId)`: Returns the tool group associated with the specified viewport.
- `getActiveToolForViewport(viewportId)`: Returns the active tool for the specified viewport.
- `destroy()`: Destroys all tool groups.
- `destroyToolGroup(toolGroupId)`: Destroys the specified tool group.
- `removeViewportFromToolGroup(viewportId, renderingEngineId, deleteToolGroupIfEmpty?)`: Removes a viewport from a tool group. If `deleteToolGroupIfEmpty` is true and the tool group becomes empty after removing the viewport, it will be destroyed.
- `addViewportToToolGroup(viewportId, renderingEngineId, toolGroupId?)`: Adds a viewport to a tool group. If `toolGroupId` is not provided, the viewport will be added to all tool groups.
- `createToolGroup(toolGroupId)`: Creates a new tool group with the specified ID.
- `addToolsToToolGroup(toolGroupId, tools, configs?)`: Adds tools to the specified tool group with optional configurations.
- `createToolGroupAndAddTools(toolGroupId, tools)`: Creates a new tool group and adds the specified tools to it.
- `getToolConfiguration(toolGroupId, toolName)`: Retrieves the configuration for the specified tool in the given tool group.
- `setToolConfiguration(toolGroupId, toolName, config)`: Sets the configuration for the specified tool in the given tool group.
## Usage
Here's an example of how to create a new tool group and add tools to it in our basic viewer mode (modes/longitudinal/src/initToolGroups.js)
```js
import { initToolGroups } from '@ohif/extension-cornerstone';
import { ToolGroupService } from '@ohif/core';
const toolGroupService = new ToolGroupService();
// Create a new tool group
const defaultToolGroup = toolGroupService.createToolGroup('default');
// Define tools for the tool group
const tools = {
active: [
{ toolName: 'WindowLevel', bindings: [{ mouseButton: 1 }] },
{ toolName: 'Pan', bindings: [{ mouseButton: 2 }] },
{ toolName: 'Zoom', bindings: [{ mouseButton: 3 }] },
],
passive: [
{ toolName: 'Length' },
{ toolName: 'ArrowAnnotate' },
{ toolName: 'Bidirectional' },
],
};
// Add tools to the tool group
toolGroupService.addToolsToToolGroup('default', tools);
```
In this example, we create a new `ToolGroupService` instance and use it to create a new tool group with the ID `'default'`. We then define an object `tools` that contains the active and passive tools we want to add to the tool group. Finally, we call the `addToolsToToolGroup` method to add the tools to the newly created tool group.
:::tip
You can begin the viewer with certain toggle tools already active. For example, if you have your 'referencelines' tool enabled, it will be active when the viewer starts, and the icon state will be correctly set to active as well.
:::
```js
const tools = {
// the reset
// enabled
enabled: [{ toolName: toolNames.ImageOverlayViewer }, { toolName: toolNames.ReferenceLines }],
};
```
![alt text](../../../assets/img/reference-lines-from-start.png)
@@ -3,16 +3,15 @@ sidebar_position: 5
sidebar_label: Toolbar Service
---
# Toolbar Service
# Toolbar **Service**
## Overview
`ToolBarService` handles the toolbar section buttons, and what happens when a
button is clicked by the user.
The `ToolBarService` is a straightforward service designed to handle the toolbar. Its main tasks include adding buttons, configuring them, and organizing button sections. When a button is clicked, it executes the designated commands. In the past, this service was more intricate, managing button states and logic. However, all that functionality has now been transferred to the `ToolBarModule` and evaluators.
<div style={{padding:"56.25% 0 0 0", position:"relative"}}>
<iframe src="https://player.vimeo.com/video/547957214?badge=0&amp;autopause=0&amp;player_id=0&amp;app_id=58479" frameBorder="0" allow="autoplay; fullscreen; picture-in-picture" allowFullScreen style= {{ position:"absolute",top:0,left:0,width:"100%",height:"100%"}} title="measurement-report"></iframe>
</div>
<!-- <div style={{padding:"56.25% 0 0 0", position:"relative"}}>
<iframe src="https://player.vimeo.com/video/547957214?badge=0&amp;autopause=0&amp;player_id=0&amp;app_id=58479" frameBorder="0" allow="autoplay; fullscreen; picture-in-picture" allowFullScreen style= {{ position:"absolute",top:0,left:0,width:"100%",height:"100%"}} **title**="measurement-report"></iframe>
</div> -->
## Events
@@ -23,20 +22,7 @@ button is clicked by the user.
## API
- `recordInteraction(interaction)`: executes the provided interaction which is
an object providing the following properties to the ToolBarService:
- `interactionType`: can be `tool`, `toggle` and `action`. We will discuss
more each type below.
- `itemId`: tool name
- `groupId`: the Id for the tool button group; e.g., `Wwwc` which holds
presets.
- `commandName`: if tool has a command attached to run
- `commandOptions`: arguments for the command.
- `setActive`: Sets a given tool active (not as primary but as secondary)
- `reset`: reset the state of the toolbarService, set the primary tool to be
`Wwwc` and unsubscribe tools that have registered their functions.
- `createButtonSection(key, buttons)` : creates a section of buttons in the toolbar with the given key and button Ids
- `addButtons`: add the button definition to the service.
[See below for button definition](#button-definitions).
@@ -44,86 +30,26 @@ button is clicked by the user.
- `setButtons`: sets the buttons defined in the service. It overrides all the
previous buttons
- `getActiveTools`: returns the active tool + all the toggled-on tools
- `setDefaultTool`: sets the default tool that will be activated whenever the primary tool is deactivated without activating another/different tool
## State
ToolBarService has an internal state that gets updated per tool interaction and
tracks the active toolId, state of the buttons that have toggled state, and the
group buttons and which tool in each group is active.
```js
state = {
primaryToolId: 'Wwwc',
toggles: {
/* id: true/false */
},
groups: {
/* track most recent click per group...*/
},
};
```
## Interaction type
There are three main types that a tool can have which is defined in the
interaction object.
- `tool`: setting a tool to be active; e.g., measurement tools
- `toggle`: toggling state of a tool; e.g., viewport link (sync)
- `action`: performs a registered action outside of the ToolBarService; e.g.,
capture
A _simplified_ implementation of the ToolBarService is:
```js
export default class ToolBarService {
/** ... **/
recordInteraction(interaction) {
/** ... **/
switch (interactionType) {
case 'action': {
break;
}
case 'tool': {
this.state.primaryToolId = itemId;
commandsManager.runCommand('setToolActive', interaction.commandOptions);
break;
}
case 'toggle': {
this.state.toggles[itemId] =
this.state.toggles[itemId] === undefined
? true
: !this.state.toggles[itemId];
interaction.commandOptions.toggledState = this.state.toggles[itemId];
break;
}
default:
throw new Error(`Invalid interaction type: ${interactionType}`);
}
/** ... **/
}
/** ... **/
}
```
## Button Definitions
### Basic
The simplest toolbarButtons definition has the following properties:
![toolbarModule-zoom](../../../assets/img/toolbarModule-zoom.png)
```js
{
"id": "Zoom",
"type": "ohif.radioGroup",
"props": {
"type": "tool",
"icon": "tool-zoom",
"label": "Zoom",
id: 'Zoom',
uiType: 'ohif.radioGroup',
props: {
icon: 'tool-zoom',
label: 'Zoom',
"commands": [
{
"commandName": "setToolActive",
@@ -133,27 +59,20 @@ The simplest toolbarButtons definition has the following properties:
"context": "CORNERSTONE"
}
]
}
}
evaluate: 'evaluate.cornerstoneTool',
},
},
```
| property | description | values |
| ---------------- | ----------------------------------------------------------------- | ------------------------------------------- |
| `id` | Unique string identifier for the definition | \* |
| `type` | Used to determine the button's behaviour | "tool", "toggle", "action" |
| `icon` | A string name for an icon supported by the consuming application. | \* |
| `label` | User/display friendly to show in UI | \* |
| `commands` | (optional) The commands to run when the button is used. It include a commandName, commandOptions, and/or a context | Any command registered by a `CommandModule` |
There are three main types of toolbar buttons:
- `tool`: buttons that enable a tool by running the `setToolActive` command with
the `commandOptions`
- `toggle`: buttons that acts as a toggle: e.g., linking viewports
- `action`: buttons that executes an action: e.g., capture button to save
screenshot
## Nested Buttons
### Nested (dropdown)
You can use the `ohif.splitButton` type to build a button with extra tools in
the dropdown.
@@ -169,91 +88,178 @@ to create `MeasurementTools` nested button
```js title="modes/longitudinal/src/toolbarButtons.js"
{
"id": "MeasurementTools",
"type": "ohif.splitButton",
"props": {
"groupId": "MeasurementTools",
"isRadio": true,
"primary": {
"id": "Length",
"icon": "tool-length",
"label": "Length",
"type": "tool",
"commands": [
{
"commandName": "setToolActive",
"commandOptions": {
"toolName": "Length"
},
"context": "CORNERSTONE"
},
{
"commandName": "setToolActive",
"commandOptions": {
"toolName": "SRLength",
"toolGroupId": "SRToolGroup"
},
"context": "CORNERSTONE"
}
id: 'MeasurementTools',
uiType: 'ohif.splitButton',
props: {
groupId: 'MeasurementToolsGroupId',
// group evaluate to determine which item should move to the top
evaluate: 'evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList',
primary: ToolbarService.createButton({
id: 'Length',
icon: 'tool-length',
label: 'Length',
tooltip: 'Length Tool',
commands: _createSetToolActiveCommands('Length'),
evaluate: 'evaluate.cornerstoneTool',
}),
secondary: {
icon: 'chevron-down',
tooltip: 'More Measure Tools',
},
items: [
ToolbarService.createButton({
id: 'Length',
icon: 'tool-length',
label: 'Length',
tooltip: 'Length Tool',
commands: [
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'Length',
},
context: 'CORNERSTONE',
},
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'SRLength',
toolGroupId: 'SRToolGroup',
},
// we can use the setToolActive command for this from Cornerstone commandsModule
context: 'CORNERSTONE',
},
],
evaluate: 'evaluate.cornerstoneTool',
}),
ToolbarService.createButton({
id: 'Bidirectional',
icon: 'tool-bidirectional',
label: 'Bidirectional',
tooltip: 'Bidirectional Tool',
commands: [
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'Bidirectional',
},
context: 'CORNERSTONE',
},
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'SRBidirectional',
toolGroupId: 'SRToolGroup',
},
context: 'CORNERSTONE',
},
],
evaluate: 'evaluate.cornerstoneTool',
}),
],
"tooltip": "Length"
},
"secondary": {
"icon": "chevron-down",
"label": "",
"isActive": true,
"tooltip": "More Measure Tools"
},
"items": [
{
"id": "Bidirectional",
"icon": "tool-bidirectional",
"label": "Bidirectional",
"type": "tool",
"commands": [
{
"commandName": "setToolActive",
"commandOptions": {
"toolName": "Bidirectional"
},
"context": "CORNERSTONE"
},
{
"commandName": "setToolActive",
"commandOptions": {
"toolName": "SRBidirectional",
"toolGroupId": "SRToolGroup"
},
"context": "CORNERSTONE"
}
],
"tooltip": "Bidirectional Tool"
},
{
"id": "ArrowAnnotate",
"icon": "tool-annotate",
"label": "Annotation",
"type": "tool",
"commands": [
{
"commandName": "setToolActive",
"commandOptions": {
"toolName": "ArrowAnnotate"
},
"context": "CORNERSTONE"
},
{
"commandName": "setToolActive",
"commandOptions": {
"toolName": "SRArrowAnnotate",
"toolGroupId": "SRToolGroup"
},
"context": "CORNERSTONE"
}
],
"tooltip": "Arrow Annotate"
},
]
}
}
},
```
:::tip
split buttons can have a group evaluator (in the above example `evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList`) which can decide what happens
when the user interacts with the buttons. In the above example, we are promoting the button to the primary section if the cornerstone tool is not active in the list of buttons.
There are other evaluators for instance `evaluate.group.promoteToPrimary`
which does not care about the cornerstone tool and promotes the button to the primary section anyway
:::
:::tip
If you don't provide a group evaluator nothing would happen and the button will stay in the secondary section.
:::
## Listeners
Sometimes you need a tool to listen to specific events in order to react properly.
You can add `listeners` for this purpose. We use this pattern for referencelineTools
which should set its source of reference upon active viewport change
Currently you can subscribe to the following events:
- `ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED`: when the active viewport changes
- `ViewportGridService.EVENTS.VIEWPORTS_READY`: when the viewports are ready in the grid
```js
const ReferenceLinesListeners: RunCommand = [
{
commandName: 'setSourceViewportForReferenceLinesTool',
context: 'CORNERSTONE',
},
];
ToolbarService.createButton({
id: 'ReferenceLines',
icon: 'tool-referenceLines',
label: 'Reference Lines',
tooltip: 'Show Reference Lines',
commands: [
{
commandName: 'setToolEnabled',
commandOptions: {
toolName: 'ReferenceLines',
toggle: true,
},
context: 'CORNERSTONE',
},
],
listeners: {
[ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: ReferenceLinesListeners,
[ViewportGridService.EVENTS.VIEWPORTS_READY]: ReferenceLinesListeners,
},
evaluate: 'evaluate.cornerstoneTool.toggle',
}),
```
## Button Sections
In order to organize the buttons, you can create button sections in the toolbar. And
assign buttons to each section separately.
OHIF provides a `primary` section by default. You can add more sections as needed in your UI
and use toolbarService to create and manage them. (You can look at the toolBox implementation
which take advantage of having a dedicated section for the tools with advanced options,
we use that in the segmentation mode).
## Example
For instance in `longitudinal` mode we are using the `onModeEnter` hook to
add the buttons to the toolbarService and assign them to the primary section.
```js title="modes/longitudinal/src/index.js"
toolbarService.addButtons([...toolbarButtons, ...moreTools]);
toolbarService.createButtonSection('primary', [
'MeasurementTools',
'Zoom',
'info',
'WindowLevel',
'Pan',
'Capture',
'Layout',
'MPR',
'Crosshairs',
'MoreTools',
]);
```
as you see we creating the button section and assigning buttons based on their Ids.
:::tip
You can even duplicate the same button in different sections and the button will be
in sync in all sections (thanks to the evaluation system).
:::
:::tip
we will add more section in the toolbar (other than primary) in the future.
:::
:::note
Don't forget to set up your toolGroups to ensure that your buttons function correctly. Buttons serve as a visual interface. When you interact with them, they execute their commands, and evaluators determine their state post-interaction.
:::
@@ -18,6 +18,8 @@ There are seven events that get publish in `ViewportGridService `:
| ACTIVE_VIEWPORT_ID_CHANGED | Fires the Id of the active viewport is changed |
| LAYOUT_CHANGED | Fires the layout is changed |
| GRID_STATE_CHANGED | Fires when the entire grid state is changed |
| VIEWPORTS_READY | Fires when the viewports are ready in the grid |
## Interface
For a more detailed look on the options and return values each of these methods
+8
View File
@@ -85,6 +85,14 @@ const config: StorybookConfig = {
include: path.resolve(__dirname, '../'),
});
// ignore the file @icr/polyseg-wasm during the build as it is a wasm file and
// we don't need that for ui
config.module.rules.push({
test: /@icr\/polyseg-wasm/,
type: 'javascript/auto',
loader: 'file-loader',
});
// Return the altered config
return config;
},
@@ -1,13 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="25px" height="25px" viewBox="0 0 25 25" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon-tool-brush</title>
<g id="Artboards" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Segmentation---assets" transform="translate(-40, -145)">
<g id="icon-tool-brush" transform="translate(40, 145)">
<path d="M3.24640621,22.3286833 C3.09173375,22.2551472 2.99513748,22.0971513 3.00018895,21.9259625 C3.00524042,21.7547737 3.11098486,21.6027485 3.26972426,21.5384606 C5.3260304,20.7059201 4.66362518,18.3620247 5.27421252,17.1088957 C6.02197747,15.6026514 7.84114758,14.9766383 9.35746132,15.7037675 C13.9485253,17.9422999 8.48346644,24.8211232 3.24640621,22.3286833 Z" id="Path" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M21.5968442,3.302022 C21.1256527,2.8826075 20.4094461,2.90228142 19.9619901,3.34693083 L9.69255027,13.5887345 C11.0536437,14.0051578 12.1843437,14.9616637 12.8206229,16.2349008 L21.7410706,4.93687606 C22.1347378,4.44008272 22.0714081,3.72222021 21.5968442,3.302022 Z" id="Path" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"></path>
<rect id="Rectangle" x="0" y="0" width="25" height="25"></rect>
</g>
</g>
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>tool-seg-brush</title>
<g id="tool-seg-brush" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<rect id="Rectangle" x="0" y="0" width="24" height="24"></rect>
<path d="M3.24640621,21.8286833 C3.09173375,21.7551472 2.99513748,21.5971513 3.00018895,21.4259625 C3.00524042,21.2547737 3.11098486,21.1027485 3.26972426,21.0384606 C5.3260304,20.2059201 4.66362518,17.8620247 5.27421252,16.6088957 C6.02197747,15.1026514 7.84114758,14.4766383 9.35746132,15.2037675 C13.9485253,17.4422999 8.48346644,24.3211232 3.24640621,21.8286833 Z" id="Path" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M21.5968442,2.302022 C21.1256527,1.8826075 20.4094461,1.90228142 19.9619901,2.34693083 L9.69255027,12.5887345 C11.0536437,13.0051578 12.1843437,13.9616637 12.8206229,15.2349008 L21.7410706,3.93687606 C22.1347378,3.44008272 22.0714081,2.72222021 21.5968442,2.302022 Z" id="Path" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1,14 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="25px" height="25px" viewBox="0 0 25 25" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon-tool-eraser</title>
<g id="Artboards" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Segmentation---assets" transform="translate(-83, -145)">
<g id="icon-tool-eraser" transform="translate(83, 145)">
<path d="M8.39130435,20.50359 C7.23520605,20.4411204 6.135127,19.9856264 5.27326087,19.2125465 L3.29104348,17.2285031 C2.58057562,16.5140665 2.58057562,15.3599396 3.29104348,14.6455031 L14.4036957,3.53285089 C15.1181322,2.82238304 16.2722591,2.82238304 16.9866957,3.53285089 L21.7080435,8.25419872 C22.4185113,8.96863525 22.4185113,10.1227622 21.7080435,10.8371987 L13.3345217,19.2107205 C12.4731497,19.9842722 11.3733983,20.4404081 10.2173913,20.50359 L8.39130435,20.50359 Z" id="Path" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"></path>
<line x1="10.2173913" y1="7.71915524" x2="17.5217391" y2="15.0235031" id="Path" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"></line>
<line x1="2" y1="22.3278509" x2="23" y2="22.3278509" id="Path" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"></line>
<rect id="Rectangle" x="0" y="0" width="25" height="25"></rect>
</g>
</g>
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>tool-seg-eraser</title>
<g id="tool-seg-eraser" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<rect id="Rectangle" x="0" y="0" width="24" height="24"></rect>
<path d="M8.47826087,18.0030772 C7.48731947,17.9495317 6.54439458,17.5591084 5.80565217,16.8964685 L4.1066087,15.1958598 C3.49763625,14.5834856 3.49763625,13.5942339 4.1066087,12.9818598 L13.6317391,3.45672934 C14.2441133,2.84775689 15.233365,2.84775689 15.8457391,3.45672934 L19.8926087,7.5035989 C20.5015811,8.11597307 20.5015811,9.10522473 19.8926087,9.7175989 L12.7153043,16.8949033 C11.9769855,17.5579476 11.0343414,17.9489213 10.0434783,18.0030772 L8.47826087,18.0030772 Z" id="Path" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
<line x1="10.0434783" y1="7.04499021" x2="16.3043478" y2="13.3058598" id="Path" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></line>
<line x1="3" y1="20.5667293" x2="21" y2="20.5667293" id="Path" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></line>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,13 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="25px" height="25px" viewBox="0 0 25 25" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon-tool-shape</title>
<g id="Artboards" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Segmentation---assets" transform="translate(-170, -193)">
<g id="icon-tool-shape" transform="translate(170, 193)">
<rect id="Rectangle" x="0" y="0" width="25" height="25"></rect>
<rect id="Rectangle" stroke="currentColor" stroke-width="1.5" x="3" y="3" width="13" height="13" rx="1"></rect>
<circle id="Oval" stroke="currentColor" stroke-width="1.5" cx="16.5" cy="16.5" r="6.5"></circle>
</g>
</g>
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>tool-seg-shape</title>
<g id="tool-seg-shape" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<rect id="Rectangle" x="0" y="0" width="24" height="24"></rect>
<circle id="Oval" stroke="currentColor" stroke-width="1.5" cx="16" cy="15" r="6"></circle>
<rect id="Rectangle" stroke="currentColor" stroke-width="1.5" x="3" y="3" width="13" height="13" rx="2"></rect>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 813 B

After

Width:  |  Height:  |  Size: 615 B

@@ -1,17 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="25px" height="25px" viewBox="0 0 25 25" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon-tool-threshold</title>
<g id="Artboards" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Segmentation---assets" transform="translate(-127, -145)">
<g id="icon-tool-threshold" transform="translate(127, 145)">
<line x1="19.4565217" y1="16.8695652" x2="22.5" y2="16.8695652" id="Line-2" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"></line>
<line x1="2.5" y1="16.8695652" x2="14.673913" y2="16.8695652" id="Line-2" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"></line>
<circle id="Oval" stroke="currentColor" stroke-width="1.25" cx="16.8478261" cy="16.8695652" r="2.17391304"></circle>
<line x1="11.6304348" y1="8.17391304" x2="22.0652174" y2="8.17391304" id="Line-2" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"></line>
<line x1="2.5" y1="8.17391304" x2="7.2826087" y2="8.17391304" id="Line-2" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"></line>
<circle id="Oval" stroke="currentColor" stroke-width="1.25" cx="9.45652174" cy="8.17391304" r="2.17391304"></circle>
<rect id="Rectangle" x="0" y="0" width="25" height="25"></rect>
</g>
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>tool-seg-threshold</title>
<g id="tool-seg-threshold" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<rect id="Rectangle" x="0" y="0" width="24" height="24"></rect>
<g id="Group" transform="translate(2.4184, 14.3673)" stroke="currentColor" stroke-width="1.5">
<line x1="16.5816327" y1="2.13265306" x2="19.0816327" y2="2.13265306" id="Line-2" stroke-linecap="round"></line>
<line x1="0.0816326531" y1="2.13265306" x2="12.0816327" y2="2.13265306" id="Line-2" stroke-linecap="round"></line>
<circle id="Oval" cx="14.2244898" cy="2.09183673" r="2.09183673"></circle>
</g>
<g id="Group" transform="translate(11.7092, 7.4592) scale(-1, 1) translate(-11.7092, -7.4592)translate(1.9184, 5.3673)" stroke="currentColor" stroke-width="1.5">
<line x1="16" y1="2.13265306" x2="19.0816327" y2="2.13265306" id="Line-2" stroke-linecap="round"></line>
<line x1="0.0816326531" y1="2.13265306" x2="11" y2="2.13265306" id="Line-2" stroke-linecap="round"></line>
<circle id="Oval" cx="13.2244898" cy="2.09183673" r="2.09183673"></circle>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -3,6 +3,10 @@ import classnames from 'classnames';
import { PanelSection, Icon, Tooltip } from '../../components';
import ToolSettings from './ToolSettings';
/**
* Use Toolbox component instead of this although it doesn't have "Advanced" in its name
* it is better to use it instead of this one
*/
const AdvancedToolbox = ({ title, items }) => {
const [activeItemName, setActiveItemName] = useState(null);
@@ -1,10 +1,11 @@
import React from 'react';
import { ButtonGroup, InputRange, ButtonEnums } from '../../components';
import { ButtonGroup, InputDoubleRange, InputRange } from '../../components';
const SETTING_TYPES = {
RANGE: 'range',
RADIO: 'radio',
CUSTOM: 'custom',
DOUBLE_RANGE: 'double-range',
};
function ToolSettings({ options }) {
@@ -12,73 +13,111 @@ function ToolSettings({ options }) {
return null;
}
const getButtons = option => {
const buttons = [];
option.values?.map(({ label, value: optionValue }, index) => {
buttons.push({
children: label,
onClick: () => option.onChange(optionValue),
key: `button-${option.id}-${index}`, // A unique key
});
});
return buttons;
};
return (
<div className="space-y-2 py-2 text-white">
{options?.map(option => {
if (option.type === SETTING_TYPES.RANGE) {
return (
<div
className="flex items-center"
key={option.id}
>
<div className="w-1/3 text-[13px]">{option.name}</div>
<div className="w-2/3">
<InputRange
minValue={option.min}
maxValue={option.max}
step={option.step}
value={option.value}
onChange={e => option.onChange(e)}
allowNumberEdit={true}
showAdjustmentArrows={false}
inputClassName="ml-1 w-4/5 cursor-pointer"
/>
</div>
</div>
);
if (option.condition && option.condition?.({ options }) === false) {
return null;
}
if (option.type === SETTING_TYPES.RADIO) {
return (
<div
className="flex items-center justify-between text-[13px]"
key={option.id}
>
<span>{option.name}</span>
<div className="max-w-1/2">
<ButtonGroup
buttons={getButtons(option)}
defaultActiveIndex={option.defaultActiveIndex}
size={ButtonEnums.size.small}
/>
</div>
</div>
);
}
if (option.type === SETTING_TYPES.CUSTOM) {
return (
<div key={option.id}>
{typeof option.children === 'function' ? option.children() : option.children}
</div>
);
switch (option.type) {
case SETTING_TYPES.RANGE:
return renderRangeSetting(option);
case SETTING_TYPES.RADIO:
return renderRadioSetting(option);
case SETTING_TYPES.DOUBLE_RANGE:
return renderDoubleRangeSetting(option);
case SETTING_TYPES.CUSTOM:
return renderCustomSetting(option);
default:
return null;
}
})}
</div>
);
}
const renderRangeSetting = option => {
return (
<div
className="flex items-center"
key={option.id}
>
<div className="w-1/3 text-[13px]">{option.name}</div>
<div className="w-2/3">
<InputRange
minValue={option.min}
maxValue={option.max}
step={option.step}
value={option.value}
onChange={value => option.commands?.(value)}
allowNumberEdit={true}
showAdjustmentArrows={false}
inputClassName="ml-1 w-4/5 cursor-pointer"
/>
</div>
</div>
);
};
const renderRadioSetting = option => {
const renderButtons = option => {
return option.values?.map(({ label, value: optionValue }, index) => (
<button
onClick={() => {
option.commands?.(optionValue);
}}
key={`button-${option.id}-${index}`}
>
{label}
</button>
));
};
return (
<div
className="flex items-center justify-between text-[13px]"
key={option.id}
>
<span>{option.name}</span>
<div className="max-w-1/2">
<ButtonGroup
activeIndex={option.values.findIndex(({ value }) => value === option.value) || 0}
>
{renderButtons(option)}
</ButtonGroup>
</div>
</div>
);
};
const renderDoubleRangeSetting = option => {
return (
<div
className="flex w-full items-center"
key={option.id}
>
<InputDoubleRange
values={option.values}
onChange={option.commands}
minValue={option.min}
maxValue={option.max}
step={option.step}
showLabel={true}
allowNumberEdit={true}
showAdjustmentArrows={false}
containerClassName="w-full"
/>
</div>
);
};
const renderCustomSetting = option => {
return (
<div key={option.id}>
{typeof option.children === 'function' ? option.children() : option.children}
</div>
);
};
export default ToolSettings;
@@ -1,2 +1,4 @@
import AdvancedToolbox from './AdvancedToolbox';
import ToolSettings from './ToolSettings';
export default AdvancedToolbox;
export { ToolSettings };
+6 -1
View File
@@ -67,9 +67,12 @@ const Button = ({
name,
className,
onClick,
dataCY,
startIconTooltip = null,
endIconTooltip = null,
}) => {
dataCY = dataCY || `${name}-btn`;
const startIcon = startIconProp && (
<>
{React.cloneElement(startIconProp, {
@@ -109,7 +112,7 @@ const Button = ({
disabled={disabled}
ref={buttonElement}
onClick={handleOnClick}
data-cy={`${name}-btn`}
data-cy={dataCY}
>
{startIconTooltip ? <Tooltip content={startIconTooltip}>{startIcon}</Tooltip> : startIcon}
{children}
@@ -148,6 +151,8 @@ Button.propTypes = {
startIconTooltip: PropTypes.node,
/** Tooltip for the end icon */
endIconTooltip: PropTypes.node,
/** Data attribute for testing */
dataCY: PropTypes.string,
};
export default Button;
@@ -1,21 +1,25 @@
import React, { useState } from 'react';
import React, { useState, useEffect, cloneElement, Children } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { ButtonEnums } from '../../components';
const ButtonGroup = ({
buttons,
onActiveIndexChange,
children,
className,
orientation = ButtonEnums.orientation.horizontal,
defaultActiveIndex = 0,
activeIndex: defaultActiveIndex = 0,
onActiveIndexChange,
disabled = false,
}) => {
const [activeIndex, setActiveIndex] = useState(defaultActiveIndex);
const handleButtonClick = (e, index) => {
useEffect(() => {
setActiveIndex(defaultActiveIndex);
}, [defaultActiveIndex]);
const handleButtonClick = index => {
setActiveIndex(index);
onActiveIndexChange && onActiveIndexChange(index);
buttons[index].onClick && buttons[index].onClick(e);
};
const orientationClasses = {
@@ -29,35 +33,40 @@ const ButtonGroup = ({
<div
className={classnames(
wrapperClasses,
'border-secondary-light rounded-[5px] border bg-black text-[13px] '
'border-secondary-light rounded-[5px] border bg-black text-[13px]'
)}
>
{buttons.map((buttonProps, index) => {
const isActive = index === activeIndex;
return (
<button
{...buttonProps}
key={index}
className={classnames(
{Children.map(children, (child, index) => {
if (React.isValidElement(child)) {
return cloneElement(child, {
key: index,
className: classnames(
'rounded-[4px] px-2 py-1',
isActive ? 'bg-customblue-40 text-white' : 'text-primary-active bg-black'
)}
onClick={e => handleButtonClick(e, index)}
/>
);
index === activeIndex
? 'bg-customblue-40 text-white'
: 'text-primary-active bg-black',
child.props.className,
disabled ? 'ohif-disabled' : ''
),
onClick: e => {
child.props.onClick && child.props.onClick(e);
handleButtonClick(index);
},
});
}
return child;
})}
</div>
);
};
ButtonGroup.propTypes = {
buttons: PropTypes.arrayOf(PropTypes.object).isRequired,
children: PropTypes.node.isRequired,
orientation: PropTypes.oneOf(Object.values(ButtonEnums.orientation)),
type: PropTypes.oneOf(Object.values(ButtonEnums.type)),
size: PropTypes.oneOf(Object.values(ButtonEnums.size)),
defaultActiveIndex: PropTypes.number,
activeIndex: PropTypes.number,
onActiveIndexChange: PropTypes.func,
className: PropTypes.string,
disabled: PropTypes.bool,
};
export default ButtonGroup;
@@ -16,10 +16,6 @@ export const argTypes = {
component={ButtonGroup}
/>
export const buttonGroupTemplate = args => {
return <ButtonGroup {...args} />;
};
<Heading
title="ButtonGroup"
componentRelativePath="ButtonGroup/ButtonGroup.tsx"
@@ -35,31 +31,16 @@ export const buttonGroupTemplate = args => {
ButtonGroup is a container for a group of buttons.
<Canvas>
<Story
name="Overview"
args={{
type: ButtonEnums.type.primary,
size: ButtonEnums.size.medium,
orientation: ButtonEnums.orientation.horizontal,
buttons: [
{
children: 'Button 1',
onClick: () => console.log('Button 1 clicked'),
},
{
children: 'Button 2',
onClick: () => console.log('Button 2 clicked'),
},
{
children: 'Button 3',
onClick: () => console.log('Button 3 clicked'),
},
],
activeIndex: 0,
onActiveIndexChange: index => console.log(index),
}}
>
{buttonGroupTemplate.bind({})}
<Story name="Overview">
<ButtonGroup
orientation={ButtonEnums.orientation.horizontal}
activeIndex={0}
onActiveIndexChange={index => console.log(index)}
>
<button onClick={() => console.log('Button 1111 clicked')}>Button 1</button>
<button onClick={() => console.log('Button 222 clicked')}>Button 2</button>
<button onClick={() => console.log('Button 33 clicked')}>Button 3</button>
</ButtonGroup>
</Story>
</Canvas>
+8 -7
View File
@@ -30,13 +30,11 @@ function Header({
return (
<NavBar
className="justify-between border-b-4 border-black"
isSticky={isSticky}
{...props}
>
<div className="flex flex-1 justify-between">
<div className="flex items-center">
{/* // TODO: Should preserve filter/sort
// Either injected service? Or context (like react router's `useLocation`?) */}
<div className="relative h-[48px] items-center ">
<div className="absolute left-0 top-1/2 -translate-y-1/2 ">
<div
className={classNames(
'mr-3 inline-flex items-center',
@@ -56,8 +54,11 @@ function Header({
</div>
</div>
</div>
<div className="flex items-center">{children}</div>
<div className="flex items-center">
{/* <div className="absolute top-1/2 left-[211px] h-8 w-20 -translate-y-1/2">{future left component}</div> */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
<div className="flex items-center justify-center space-x-2">{children}</div>
</div>
<div className="absolute right-0 top-1/2 -translate-y-1/2 ">
<Dropdown
id="options"
showDropdownIcon={false}
+2 -2
View File
@@ -94,7 +94,7 @@ import toolMove from './../../assets/icons/tool-move.svg';
import toolWindow from './../../assets/icons/tool-window-level.svg';
import toolAnnotate from './../../assets/icons/tool-annotate.svg';
import toolBidirectional from './../../assets/icons/tool-bidirectional.svg';
import toolElipse from './../../assets/icons/tool-elipse.svg';
import toolElipse from './../../assets/icons/tool-ellipse.svg';
import toolCircle from './../../assets/icons/tool-circle.svg';
import toolLength from './../../assets/icons/tool-length.svg';
import toolStackScroll from './../../assets/icons/tool-stack-scroll.svg';
@@ -235,7 +235,7 @@ const ICONS = {
'tool-window-level': toolWindow,
'tool-annotate': toolAnnotate,
'tool-bidirectional': toolBidirectional,
'tool-elipse': toolElipse,
'tool-ellipse': toolElipse,
'tool-circle': toolCircle,
'tool-length': toolLength,
'tool-stack-scroll': toolStackScroll,
@@ -14,7 +14,7 @@ const roundedClasses = {
};
const disabledClasses = {
true: 'cursor-not-allowed',
true: 'ohif-disabled',
false: '',
};
@@ -43,7 +43,7 @@ const variantClasses = {
'border border-primary-main text-white hover:bg-primary-main focus:bg-primary-main hover:border-black focus:border-black',
},
contained: {
default: 'text-black hover:opacity-80 active:opacity-100 focus:opacity-80',
default: 'text-common-bright hover:opacity-80 active:opacity-100 focus:opacity-80',
primary: 'text-white hover:opacity-80 active:opacity-100 focus:opacity-80',
secondary: 'text-white hover:opacity-80 active:opacity-100 focus:opacity-80',
white: 'text-black hover:opacity-80 active:opacity-100 focus:opacity-80',
@@ -51,30 +51,6 @@ const variantClasses = {
},
};
const backgroundClasses = {
text: {
default: '',
primary: '',
secondary: '',
white: '',
black: '',
},
outlined: {
default: 'bg-transparent',
primary: 'bg-transparent',
secondary: 'bg-transparent',
white: 'bg-transparent',
black: 'bg-black',
},
contained: {
default: 'bg-primary-light',
primary: 'bg-primary-main',
secondary: 'bg-secondary-light',
white: 'bg-white',
black: 'bg-black',
},
};
const sizeClasses = {
small: 'py-2 px-2 text-base',
medium: 'py-3 px-3 text-lg',
@@ -87,7 +63,7 @@ const iconSizeClasses = {
small: 'w-4 h-4',
medium: 'w-5 h-5',
large: 'w-6 h-6',
toolbar: 'w-5 h-5',
toolbar: 'w-[24px] h-[24px]',
};
const fullWidthClasses = {
@@ -106,9 +82,7 @@ const IconButton = ({
fullWidth,
onClick,
className,
name,
id,
bgColor,
...rest
}) => {
const buttonElement = useRef(null);
@@ -118,7 +92,7 @@ const IconButton = ({
onClick(e);
};
const bgColorToUse = bgColor ? bgColor : backgroundClasses[variant][color];
const padding = size === 'toolbar' ? '8px' : size === 'toolbox' ? '4px' : null;
return (
<button
@@ -129,11 +103,10 @@ const IconButton = ({
sizeClasses[size],
fullWidthClasses[fullWidth],
disabledClasses[disabled],
bgColorToUse,
className
)}
style={{
padding: size === 'toolbar' ? '10px' : null,
padding,
}}
ref={buttonElement}
onClick={handleOnClick}
@@ -161,7 +134,7 @@ IconButton.defaultProps = {
IconButton.propTypes = {
children: PropTypes.node.isRequired,
size: PropTypes.oneOf(['small', 'medium', 'large', 'initial', 'toolbar']),
size: PropTypes.oneOf(['small', 'medium', 'large', 'initial', 'toolbar', 'toolbox']),
rounded: PropTypes.oneOf(['none', 'small', 'medium', 'large', 'full']),
variant: PropTypes.oneOf(['text', 'outlined', 'contained']),
color: PropTypes.oneOf(['default', 'primary', 'secondary', 'white', 'black', 'inherit']),
@@ -0,0 +1,170 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import OutsideClickHandler from 'react-outside-click-handler';
import { useTranslation } from 'react-i18next';
import Icon from '../Icon';
import Tooltip from '../Tooltip';
import ListMenu from '../ListMenu';
const baseClasses = {
Button: 'flex items-center rounded-md border-transparent cursor-pointer group/button',
Primary:
// By default border on left, top and bottom for hover effect and only rounded on left side.
// Extra padding on right to compensate for no right border.
'h-full border-l-2 border-t-2 border-b-2 rounded-tl-md rounded-bl-md group/primary !pl-2 !py-2',
Secondary:
'h-full flex items-center justify-center rounded-tr-md rounded-br-md w-4 border-2 border-transparent group/secondary',
SecondaryIcon: 'w-4 h-full stroke-1',
Separator: 'border-l py-2.5',
Content: 'absolute z-10 top-0 mt-12',
};
const classes = {
Button: ({ isExpanded, primary }) =>
classNames(
baseClasses.Button,
!isExpanded && !primary.isActive && 'hover:!bg-primary-dark hover:border-primary-dark'
),
Interface: 'h-full flex flex-row items-center',
Primary: ({ isActive, isExpanded }) =>
classNames(
baseClasses.Primary,
isActive
? isExpanded
? 'border-primary-dark !bg-primary-dark hover:border-primary-dark !text-primary-light'
: 'border-primary-light bg-primary-light border-2 rounded-md !p-2'
: `focus:!text-black focus:!rounded-md focus:!border-primary-light focus:!bg-primary-light
${
isExpanded
? 'border-primary-dark bg-primary-dark !text-primary-light'
: 'border-secondary-dark bg-secondary-dark group-hover/button:border-primary-dark group-hover/button:text-primary-light hover:!bg-primary-dark hover:border-primary-dark focus:!text-black'
}
`
),
Secondary: ({ isExpanded, primary }) =>
classNames(
baseClasses.Secondary,
isExpanded
? 'bg-primary-light !rounded-tr-md !rounded-br-md'
: primary.isActive
? 'bg-secondary-dark'
: 'hover:bg-primary-dark bg-secondary-dark group-hover/button:border-primary-dark'
),
SecondaryIcon: ({ isExpanded }) =>
classNames(
baseClasses.SecondaryIcon,
isExpanded
? 'text-primary-dark'
: 'text-primary-active group-hover/secondary:text-primary-light'
),
Separator: ({ primary, isExpanded, isHovering }) =>
classNames(
baseClasses.Separator,
isHovering || isExpanded || primary.isActive ? 'border-transparent' : 'border-primary-active'
),
Content: ({ isExpanded }) => classNames(baseClasses.Content, isExpanded ? 'block' : 'hidden'),
};
const SplitButton = ({
groupId,
primary,
secondary,
items,
renderer,
onInteraction,
Component,
isActive,
}) => {
const { t } = useTranslation('Buttons');
const [state, setState] = useState({ isHovering: false, isExpanded: false });
const toggleExpanded = () => setState({ ...state, isExpanded: !state.isExpanded });
const setHover = hovering => setState({ ...state, isHovering: hovering });
const collapse = () => setState({ ...state, isExpanded: false });
const renderPrimaryButton = () => (
<Component
key={primary.id}
{...primary}
isActive={isActive}
onInteraction={onInteraction}
rounded="none"
className={classes.Primary({ isActive, isExpanded: state.isExpanded })}
data-tool={primary.id}
data-cy={`${groupId}-split-button-primary`}
/>
);
return (
<OutsideClickHandler
onOutsideClick={collapse}
disabled={!state.isExpanded}
>
<div
id="SplitButton"
className="relative"
>
<div
className={classes.Button({ ...state, primary: { isActive } })}
style={{ height: '40px' }}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<div className={classes.Interface}>
<div onClick={collapse}>{renderPrimaryButton()}</div>
<div className={classes.Separator({ ...state, primary: { isActive } })}></div>
<div
className={classes.Secondary({ ...state, primary: { isActive } })}
onClick={toggleExpanded}
data-cy={`${groupId}-split-button-secondary`}
>
<Tooltip
isDisabled={state.isExpanded || !secondary.tooltip}
content={secondary.tooltip}
className="h-full"
>
<Icon
name={secondary.icon}
className={classes.SecondaryIcon({ ...state })}
/>
</Tooltip>
</div>
</div>
</div>
<div
className={classes.Content({ ...state })}
data-cy={`${groupId}-list-menu`}
>
<ListMenu
items={items}
onClick={collapse}
renderer={args => renderer({ ...args, t })}
/>
</div>
</div>
</OutsideClickHandler>
);
};
SplitButton.propTypes = {
isToggle: PropTypes.bool,
groupId: PropTypes.string.isRequired,
primary: PropTypes.object.isRequired,
secondary: PropTypes.object.isRequired,
items: PropTypes.array.isRequired,
renderer: PropTypes.func,
isActive: PropTypes.bool,
onInteraction: PropTypes.func.isRequired,
Component: PropTypes.elementType,
};
SplitButton.defaultProps = {
isToggle: false,
renderer: null,
isActive: false,
Component: null,
};
export default SplitButton;
@@ -0,0 +1,2 @@
import LegacySplitButton from './LegacySplitButton';
export default LegacySplitButton;
@@ -2,24 +2,25 @@ import React, { useState } from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
const flex = 'flex flex-row justify-between items-center';
const theme = 'bg-indigo-dark text-white';
const ListMenu = ({ items = [], renderer, onClick }) => {
const [selectedIndex, setSelectedIndex] = useState(null);
const ListItem = ({ item, index, isSelected }) => {
const flex = 'flex flex-row justify-between items-center';
const theme = 'bg-indigo-dark';
const onClickHandler = () => {
setSelectedIndex(index);
onClick({ item, selectedIndex: index });
if (item.onClick) {
item.onClick({ ...item, index, isSelected });
}
item.onClick?.({ ...item, index, isSelected });
};
return (
<div
className={classnames(flex, theme, 'cursor-pointer')}
className={classnames(flex, theme, {
'cursor-pointer': !item.disabled,
'ohif-disabled': item.disabled,
})}
onClick={onClickHandler}
data-cy={item.id}
>
+1 -2
View File
@@ -9,12 +9,11 @@ const NavBar = ({ className, children, isSticky }) => {
return (
<div
className={classnames(
'bg-secondary-dark z-20 flex flex-row items-center border-b-4 border-black px-1',
'bg-secondary-dark z-20 border-black px-1',
isSticky && stickyClasses,
!isSticky && notStickyClasses,
className
)}
style={{ paddingTop: '4px', paddingBottom: '4px', minHeight: '52px' }}
>
{children}
</div>
@@ -37,7 +37,7 @@ const SegmentItem = ({
>
<div
className={classnames('bg-primary-dark group/number grid w-[32px] place-items-center', {
'bg-primary-light border-primary-light rounded-l-[4px] border text-black': isActive,
'!bg-primary-light border-primary-light rounded-l-[4px] border text-black': isActive,
'border-primary-dark border': !isActive,
})}
onMouseEnter={() => setIsNumberBoxHovering(true)}
@@ -98,10 +98,11 @@ const getTabClassNames = (
numColumns: number,
numTabs: number,
tabIndex: number,
isActiveTab: boolean
isActiveTab: boolean,
isTabDisabled: boolean
) =>
classnames('h-[28px] mb-[2px] cursor-pointer text-white bg-black', {
'hover:text-primary-active': !isActiveTab,
'hover:text-primary-active': !isActiveTab && !isTabDisabled,
'rounded-l': tabIndex % numColumns === 0,
'rounded-r': (tabIndex + 1) % numColumns === 0 || tabIndex === numTabs - 1,
});
@@ -137,6 +138,15 @@ const createStyleMap = (
};
};
const getToolTipContent = (label: string, disabled: boolean) => {
return (
<>
<div>{label}</div>
{disabled && <div className="text-white">{'Not available based on current context'}</div>}
</>
);
};
const createBaseStyle = (expandedWidth: number) => {
return {
maxWidth: `${expandedWidth}px`,
@@ -218,7 +228,7 @@ const SidePanel = ({
<Tooltip
position={side === 'left' ? 'right' : 'left'}
key={index}
content={`${childComponent.label}`}
content={getToolTipContent(childComponent.label, childComponent.disabled)}
className={classnames(
'flex items-center',
side === 'left' ? 'justify-end ' : 'justify-start '
@@ -229,12 +239,15 @@ const SidePanel = ({
data-cy={`${childComponent.name}-btn`}
className="text-primary-active hover:cursor-pointer"
onClick={() => {
updateActiveTabIndex(index);
return childComponent.disabled ? null : updateActiveTabIndex(index);
}}
>
<Icon
name={childComponent.iconName}
className="text-primary-active"
className={classnames({
'text-primary-active': true,
'ohif-disabled': childComponent.disabled,
})}
style={{
width: '22px',
height: '22px',
@@ -279,6 +292,7 @@ const SidePanel = ({
style={getGridStyle(side, tabs.length, gridWidth, expandedWidth)}
>
{tabs.map((tab, tabIndex) => {
const { disabled } = tab;
return (
<React.Fragment key={tabIndex}>
{tabIndex % numCols !== 0 && (
@@ -294,22 +308,26 @@ const SidePanel = ({
<Tooltip
position={'bottom'}
key={tabIndex}
content={`${tab.label}`}
content={getToolTipContent(tab.label, disabled)}
>
<div
className={getTabClassNames(
numCols,
tabs.length,
tabIndex,
tabIndex === activeTabIndex
tabIndex === activeTabIndex,
disabled
)}
style={getTabStyle(tabs.length)}
onClick={() => updateActiveTabIndex(tabIndex)}
onClick={() => {
return disabled ? null : updateActiveTabIndex(tabIndex);
}}
data-cy={`${tab.name}-btn`}
>
<div className={getTabIconClassNames(tabs.length, tabIndex === activeTabIndex)}>
<Icon
name={tab.iconName}
className={`${tab.disabled && 'ohif-disabled'}`}
style={{
width: '22px',
height: '22px',
@@ -11,42 +11,29 @@ import ListMenu from '../ListMenu';
const baseClasses = {
Button: 'flex items-center rounded-md border-transparent cursor-pointer group/button',
Primary:
// By default border on left, top and bottom for hover effect and only rounded on left side.
// Extra padding on right to compensate for no right border.
'h-full border-l-2 border-t-2 border-b-2 rounded-tl-md rounded-bl-md group/primary !pl-2 !py-2',
Secondary:
'h-full flex items-center justify-center rounded-tr-md rounded-br-md w-4 border-2 border-transparent group/secondary',
SecondaryIcon: 'w-4 h-full stroke-1',
Separator: 'border-l py-2.5',
Separator: 'border-l py-3 ml-0.5',
Content: 'absolute z-10 top-0 mt-12',
};
const classes = {
Button: ({ isExpanded, primary }) =>
Button: ({ isExpanded }) =>
classNames(
baseClasses.Button,
!isExpanded && !primary.isActive && 'hover:!bg-primary-dark hover:border-primary-dark'
!isExpanded && 'hover:!bg-primary-dark hover:border-primary-dark'
),
Interface: 'h-full flex flex-row items-center',
Primary: ({ primary, isExpanded }) =>
Primary: ({ isExpanded, isActive }) =>
classNames(
baseClasses.Primary,
primary.isActive
isActive
? isExpanded
? 'border-primary-dark !bg-primary-dark hover:border-primary-dark !text-primary-light'
: `${
primary.isToggle
? 'border-secondary-dark bg-secondary-light'
: 'border-primary-light bg-primary-light'
}
border-2 rounded-md !p-2` // Full, rounded border with less right padding when active.
: `focus:!text-black focus:!rounded-md focus:!border-primary-light focus:!bg-primary-light
${
isExpanded
? 'border-primary-dark bg-primary-dark !text-primary-light'
: 'border-secondary-dark bg-secondary-dark group-hover/button:border-primary-dark group-hover/button:text-primary-light hover:!bg-primary-dark hover:border-primary-dark focus:!text-black'
}
`
: 'border-primary-light bg-primary-light border-2 rounded-md !p-2'
: `focus:!text-black focus:!rounded-md focus:!border-primary-light focus:!bg-primary-light ${isExpanded ? 'border-primary-dark bg-primary-dark !text-primary-light' : 'border-secondary-dark bg-secondary-dark group-hover/button:border-primary-dark group-hover/button:text-primary-light hover:!bg-primary-dark hover:border-primary-dark focus:!text-black'}`
),
Secondary: ({ isExpanded, primary }) =>
classNames(
@@ -54,13 +41,15 @@ const classes = {
isExpanded
? 'bg-primary-light !rounded-tr-md !rounded-br-md'
: primary.isActive
? 'bg-secondary-dark'
: 'hover:bg-primary-dark bg-secondary-dark group-hover/button:border-primary-dark'
? 'bg-secondary-dark'
: 'hover:bg-primary-dark bg-secondary-dark group-hover/button:border-primary-dark'
),
SecondaryIcon: ({ isExpanded }) =>
classNames(
baseClasses.SecondaryIcon,
isExpanded ? 'text-primary-dark' : 'text-[#348cfd] group-hover/secondary:text-primary-light'
isExpanded
? 'text-primary-dark'
: 'text-primary-active group-hover/secondary:text-primary-light'
),
Separator: ({ primary, isExpanded, isHovering }) =>
classNames(
@@ -70,14 +59,40 @@ const classes = {
Content: ({ isExpanded }) => classNames(baseClasses.Content, isExpanded ? 'block' : 'hidden'),
};
const DefaultListItemRenderer = props => {
const { t, icon, label, className, isActive } = props;
return (
<div
className={classNames(
'flex h-8 w-full flex-row items-center p-3',
'whitespace-pre text-base',
className,
`${isActive ? 'hover:opacity-80' : 'hover:bg-primary-dark '}`
)}
>
{icon && (
<span className="mr-4">
<Icon
name={icon}
className="h-5 w-5"
/>
</span>
)}
<span className="mr-5">{t?.(label)}</span>
</div>
);
};
/**
* This is a more generic version of SplitButton without the isActive
* and other interaction props
*/
const SplitButton = ({
isToggle,
groupId,
primary,
secondary,
items,
renderer,
isActive,
onInteraction,
Component,
}) => {
@@ -88,19 +103,14 @@ const SplitButton = ({
const setHover = hovering => setState({ ...state, isHovering: hovering });
const collapse = () => setState({ ...state, isExpanded: false });
const renderPrimaryButton = () => (
<Component
key={primary.id}
{...primary}
isActive={isActive}
onInteraction={onInteraction}
rounded="none"
className={classes.Primary({ ...state, primary: { isActive, isToggle } })}
data-tool={primary.id}
data-cy={`${groupId}-split-button-primary`}
/>
const listItemRenderer = renderer || DefaultListItemRenderer;
const primaryClassNames = classNames(
classes.Primary({
isExpanded: state.isExpanded,
isActive: primary.isActive,
}),
primary.className
);
return (
<OutsideClickHandler
onOutsideClick={collapse}
@@ -111,16 +121,26 @@ const SplitButton = ({
className="relative"
>
<div
className={classes.Button({ ...state, primary: { isActive } })}
className={classes.Button({ ...state })}
style={{ height: '40px' }}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<div className={classes.Interface}>
<div onClick={collapse}>{renderPrimaryButton()}</div>
<div className={classes.Separator({ ...state, primary: { isActive } })}></div>
<div onClick={collapse}>
<Component
key={primary.id}
{...primary}
onInteraction={onInteraction}
rounded="none"
className={primaryClassNames}
data-tool={primary.id}
data-cy={`${groupId}-split-button-primary`}
/>
</div>
<div className={classes.Separator({ ...state, primary })}></div>
<div
className={classes.Secondary({ ...state, primary: { isActive } })}
className={classes.Secondary({ ...state, primary })}
onClick={toggleExpanded}
data-cy={`${groupId}-split-button-secondary`}
>
@@ -131,7 +151,7 @@ const SplitButton = ({
>
<Icon
name={secondary.icon}
className={classes.SecondaryIcon({ ...state, primary: { isActive } })}
className={classes.SecondaryIcon({ ...state })}
/>
</Tooltip>
</div>
@@ -144,7 +164,7 @@ const SplitButton = ({
<ListMenu
items={items}
onClick={collapse}
renderer={args => renderer({ ...args, t })}
renderer={args => listItemRenderer({ ...args, t })}
/>
</div>
</div>
@@ -162,6 +182,7 @@ SplitButton.propTypes = {
isActive: PropTypes.bool,
onInteraction: PropTypes.func.isRequired,
Component: PropTypes.elementType,
interactionType: PropTypes.oneOf(['action', 'tool', 'toggle']),
};
SplitButton.defaultProps = {
@@ -2,6 +2,7 @@ import SplitButton from '../SplitButton';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
import ToolbarButton from '../../ToolbarButton';
export const argTypes = {
component: SplitButton,
@@ -30,7 +31,7 @@ export const SplitButtonTemplate = args => (
## Overview
SplitButton is a component that renders the SplitButtons.
SplitButton is a component that renders the SplitButtons
<Canvas>
<Story
@@ -63,6 +64,7 @@ SplitButton is a component that renders the SplitButtons.
tooltip: 'Bidirectional',
},
],
Component: ToolbarButton,
}}
>
{SplitButtonTemplate.bind({})}
@@ -64,7 +64,7 @@ const Thumbnail = ({
<img
src={imageSrc}
alt={imageAltText}
className="min-h-32 object-none"
className="min-h-32 object-contain"
crossOrigin="anonymous"
/>
) : (
@@ -8,7 +8,9 @@ import Tooltip from '../Tooltip';
import { StringNumber } from '../../types';
import { useTranslation } from 'react-i18next';
function ThumbnailTracked ({
// Todo: This class to me feels like it belongs in an extension, not in platform/ui
// because it is dealing with mode specific components/information
function ThumbnailTracked({
displaySetInstanceUID,
className,
imageSrc,
@@ -87,12 +89,12 @@ function ThumbnailTracked ({
</div>
<div className="flex flex-1 flex-col">
<span>
<span className="text-white">{isTracked ? t('Series is tracked') : t('Series is untracked')}</span>
<span className="text-white">
{isTracked ? t('Series is tracked') : t('Series is untracked')}
</span>
</span>
{!!viewportIdentificator.length && (
<span>
{`${t('Viewport')}: ${viewportIdentificatorLabel}`}
</span>
<span>{`${t('Viewport')}: ${viewportIdentificatorLabel}`}</span>
)}
</div>
</div>
@@ -136,7 +138,7 @@ function ThumbnailTracked ({
/>
</div>
);
};
}
ThumbnailTracked.propTypes = {
/**
@@ -1,13 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import IconButton from '../IconButton';
import Icon from '../Icon';
import Tooltip from '../Tooltip';
const ToolbarButton = ({
type = 'tool',
id,
icon,
label,
@@ -15,47 +13,37 @@ const ToolbarButton = ({
onInteraction,
dropdownContent,
//
isActive,
className,
size,
toolTipClassName,
...rest
//
}) => {
const classes = {
tool: isActive
? 'text-black'
: 'text-common-bright hover:!bg-primary-dark hover:text-primary-light',
toggle: isActive
? '!text-[#348CFD]'
: 'text-common-bright hover:!bg-primary-dark hover:text-primary-light',
action: isActive
? 'text-black'
: 'text-common-bright hover:!bg-primary-dark hover:text-primary-light',
};
const bgClasses = {
toggle: isActive && 'bg-transparent',
};
const activeClass = isActive ? 'active' : '';
const shouldShowDropdown = !!isActive && !!dropdownContent;
const shouldShowDropdown = !!dropdownContent;
const iconEl = icon ? <Icon name={icon} /> : <div>{label || 'Missing icon and label'}</div>;
const sizeToUse = size ?? 'toolbar';
const toolTipClassNameToUse =
toolTipClassName !== undefined
? toolTipClassName
: sizeToUse === 'toolbar'
? 'w-[40px] h-[40px]'
: 'w-[32px] h-[32px]';
return (
<div key={id}>
<Tooltip
isSticky={shouldShowDropdown}
content={shouldShowDropdown ? dropdownContent : label}
tight={shouldShowDropdown}
className={toolTipClassNameToUse}
>
<IconButton
variant={isActive ? 'contained' : 'text'}
bgColor={bgClasses[type]}
size="toolbar"
className={classnames(activeClass, classes[type], className)}
size={sizeToUse}
className={className}
onClick={() => {
onInteraction({
itemId: id,
interactionType: type,
commands,
});
}}
@@ -73,27 +61,20 @@ const ToolbarButton = ({
ToolbarButton.defaultProps = {
dropdownContent: null,
isActive: false,
type: 'action',
};
ToolbarButton.propTypes = {
/* Influences background/hover styling */
type: PropTypes.oneOf(['action', 'toggle', 'tool']),
id: PropTypes.string.isRequired,
isActive: PropTypes.bool,
className: PropTypes.string,
commands: PropTypes.arrayOf(
PropTypes.shape({
commandName: PropTypes.string.isRequired,
commandOptions: PropTypes.object,
})
),
onInteraction: PropTypes.func.isRequired,
commands: PropTypes.oneOfType([PropTypes.array, PropTypes.object, PropTypes.string]),
onInteraction: PropTypes.func,
icon: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
/** Tooltip content can be replaced for a customized content by passing a node to this value. */
dropdownContent: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
size: PropTypes.string,
toolTipClassName: PropTypes.string,
};
export default ToolbarButton;
@@ -0,0 +1,109 @@
import React, { useEffect, useRef } from 'react';
import { useToolbar } from '@ohif/core';
import { ToolboxUI } from './';
import { useToolbox } from '../../contextProviders';
/**
* A toolbox is a collection of buttons and commands that they invoke, used to provide
* custom control panels to users. This component is a generic UI component that
* interacts with services and commands in a generic fashion. While it might
* seem unconventional to import it from the UI and integrate it into the JSX,
* it belongs in the UI components as there isn't anything in this component that
* couldn't be used for a completely different type of app. It plays a crucial
* role in enhancing the app with a toolbox by providing a way to integrate
* and display various tools and their corresponding options
*/
function Toolbox({ servicesManager, buttonSectionId, commandsManager, title, ...props }) {
const { state: toolboxState, api } = useToolbox(buttonSectionId);
const { onInteraction, toolbarButtons } = useToolbar({
servicesManager,
buttonSection: buttonSectionId,
});
const prevButtonIdsRef = useRef();
useEffect(() => {
const currentButtonIdsStr = JSON.stringify(
toolbarButtons.map(button => {
const { id, componentProps } = button;
if (componentProps.items?.length) {
return componentProps.items.map(item => `${item.id}-${item.disabled}`);
}
return `${id}-${componentProps.disabled}`;
})
);
if (prevButtonIdsRef.current === currentButtonIdsStr) {
return;
}
prevButtonIdsRef.current = currentButtonIdsStr;
const initializeOptionsWithEnhancements = toolbarButtons.reduce(
(accumulator, toolbarButton) => {
const { id: buttonId, componentProps } = toolbarButton;
const createEnhancedOptions = (options, parentId) =>
options.map(option => {
return {
...option,
value:
toolboxState.toolOptions?.[parentId]?.find(prop => prop.id === option.id)?.value ??
option.value,
commands: value => {
api.handleToolOptionChange(parentId, option.id, value);
const { isArray } = Array;
const cmds = isArray(option.commands) ? option.commands : [option.commands];
cmds.forEach(command => {
const isString = typeof command === 'string';
const isObject = typeof command === 'object';
if (isString) {
commandsManager.run(command, { value });
} else if (isObject) {
commandsManager.run({
...command,
commandOptions: { ...command.commandOptions, ...option, value },
});
}
});
},
};
});
if (componentProps.items?.length) {
componentProps.items.forEach(item => {
accumulator[item.id] = createEnhancedOptions(item.options, item.id);
});
} else if (componentProps.options?.length) {
accumulator[buttonId] = createEnhancedOptions(componentProps.options, buttonId);
}
return accumulator;
},
{}
);
api.initializeToolOptions(initializeOptionsWithEnhancements);
}, [toolbarButtons, api]);
const handleToolOptionChange = (toolName, optionName, newValue) => {
api.handleToolOptionChange(toolName, optionName, newValue);
};
return (
<ToolboxUI
{...props}
title={title}
toolbarButtons={toolbarButtons}
activeToolOptions={toolboxState.toolOptions?.[toolboxState.activeTool]}
handleToolSelect={id => api.handleToolSelect(id)}
handleToolOptionChange={handleToolOptionChange}
onInteraction={onInteraction}
/>
);
}
export default Toolbox;
@@ -0,0 +1,92 @@
import React from 'react';
import { PanelSection, ToolSettings, Tooltip } from '../../components';
import classnames from 'classnames';
const ItemsPerRow = 4;
/**
* Just refactoring from the toolbox component to make it more readable
*/
function ToolboxUI(props) {
const {
toolbarButtons,
handleToolSelect,
activeToolOptions,
numRows,
servicesManager,
title,
useCollapsedPanel = true,
} = props;
const render = () => {
return (
<>
<div className="flex flex-col bg-black">
<div className="bg-primary-dark mt-0.5 flex flex-wrap py-2">
{toolbarButtons.map((toolDef, index) => {
if (!toolDef) {
return null;
}
const { id, Component, componentProps } = toolDef;
const isLastRow = Math.floor(index / ItemsPerRow) + 1 === numRows;
const toolClasses = `ml-1 ${isLastRow ? '' : 'mb-2'}`;
const onInteraction = ({ itemId, id, commands }) => {
handleToolSelect(itemId || id);
props.onInteraction({
itemId,
commands,
});
};
return (
<div
key={id}
className={classnames({
[toolClasses]: true,
'flex flex-col items-center justify-center': true,
})}
>
{componentProps.disabled ? (
<Tooltip
position="bottom"
content={componentProps.label}
secondaryContent={'Not available on the current viewport'}
>
<Component
{...componentProps}
{...props}
id={id}
servicesManager={servicesManager}
onInteraction={onInteraction}
size="toolbox"
/>
</Tooltip>
) : (
<Component
{...componentProps}
{...props}
id={id}
servicesManager={servicesManager}
onInteraction={onInteraction}
size="toolbox"
/>
)}
</div>
);
})}
</div>
</div>
<div className="bg-primary-dark h-auto px-2">
{activeToolOptions && <ToolSettings options={activeToolOptions} />}
</div>
</>
);
};
return useCollapsedPanel ? <PanelSection title={title}>{render()}</PanelSection> : render();
}
export { ToolboxUI };
@@ -0,0 +1,3 @@
import { ToolboxUI } from './ToolboxUI';
import Toolbox from './Toolbox';
export { ToolboxUI, Toolbox };
+15 -3
View File
@@ -30,7 +30,16 @@ const arrowPositionStyle = {
},
};
const Tooltip = ({ content, isSticky, position, className, tight, children, isDisabled }) => {
const Tooltip = ({
content,
secondaryContent = null,
isSticky,
position,
className,
tight,
children,
isDisabled,
}) => {
const [isActive, setIsActive] = useState(false);
const { t } = useTranslation('Buttons');
@@ -68,11 +77,14 @@ const Tooltip = ({ content, isSticky, position, className, tight, children, isDi
className={classnames(
'tooltip-box bg-primary-dark border-secondary-light w-max-content relative inset-x-auto top-full rounded border text-base text-white',
{
'py-1 px-4': !tight,
'py-[6px] px-[8px]': !tight,
}
)}
>
{typeof content === 'string' ? t(content) : content}
<div>{typeof content === 'string' ? t(content) : content}</div>
<div className="text-aqua-pale">
{typeof secondaryContent === 'string' ? t(secondaryContent) : secondaryContent}
</div>
<svg
className="text-primary-dark stroke-secondary-light absolute h-4"
style={arrowPositionStyle[position]}
@@ -10,10 +10,24 @@ const classes = {
bottomLeft: 'bottom-viewport left-viewport',
};
const ViewportOverlay = ({ topLeft, topRight, bottomRight, bottomLeft, color }) => {
export type ViewportOverlayProps = {
topLeft: React.ReactNode;
topRight: React.ReactNode;
bottomRight: React.ReactNode;
bottomLeft: React.ReactNode;
color?: string;
};
const ViewportOverlay = ({
topLeft,
topRight,
bottomRight,
bottomLeft,
color = 'text-primary-light',
}: ViewportOverlayProps) => {
const overlay = 'absolute pointer-events-none viewport-overlay';
return (
<div className={classnames(color ? color : 'text-primary-light')}>
<div className={color}>
<div
data-cy={'viewport-overlay-top-left'}
className={classnames(overlay, classes.topLeft)}
+6
View File
@@ -79,6 +79,9 @@ import PanelSection from './PanelSection';
import AdvancedToolbox from './AdvancedToolbox';
import InputDoubleRange from './InputDoubleRange';
import LegacyButtonGroup from './LegacyButtonGroup';
import LegacySplitButton from './LegacySplitButton';
import { ToolSettings } from './AdvancedToolbox';
import { Toolbox } from './Toolbox';
import InvestigationalUseDialog from './InvestigationalUseDialog';
import MeasurementItem from './MeasurementTable/MeasurementItem';
@@ -165,6 +168,9 @@ export {
ViewportPane,
ViewportOverlay,
WindowLevelMenuItem,
LegacySplitButton,
ToolSettings,
Toolbox,
InvestigationalUseDialog,
MeasurementItem,
};
@@ -73,7 +73,7 @@ export default function CineProvider({ children, service }) {
const api = {
getState,
setCine,
setIsCineEnabled,
setIsCineEnabled: isCineEnabled => service.setIsCineEnabled(isCineEnabled),
playClip: (element, playClipOptions) => service.playClip(element, playClipOptions),
stopClip: element => service.stopClip(element),
};
@@ -1,18 +1,17 @@
import React, { createContext, useContext, useReducer, useMemo } from 'react';
import React, { ReactNode, createContext, useContext, useMemo } from 'react';
// export const IMAGE_VIEWER_DEFAULT_VALUE = {
// StudyInstanceUIDs: [],
// setImageViewer: () => {},
// };
interface ImageViewerProviderProps {
StudyInstanceUIDs: string[];
children: ReactNode;
}
export const ImageViewerContext = createContext();
export const ImageViewerContext = createContext(null);
export const useImageViewer = () => useContext(ImageViewerContext);
export function ImageViewerProvider({ StudyInstanceUIDs, reducer, initialState, children }) {
export function ImageViewerProvider({ StudyInstanceUIDs, children }: ImageViewerProviderProps) {
const value = useMemo(() => {
return { StudyInstanceUIDs };
}, [StudyInstanceUIDs]);
return <ImageViewerContext.Provider value={value}>{children}</ImageViewerContext.Provider>;
}
export const useImageViewer = () => useContext(ImageViewerContext);
@@ -0,0 +1,106 @@
import React, { createContext, useContext, useReducer } from 'react';
export const initialState = {};
export const toolboxReducer = (state, action) => {
const { toolbarSectionId } = action.payload;
if (!state[toolbarSectionId]) {
state[toolbarSectionId] = { activeTool: null, toolOptions: {} };
}
switch (action.type) {
case 'SET_ACTIVE_TOOL':
return {
...state,
[toolbarSectionId]: {
...state[toolbarSectionId],
activeTool: action.payload.activeTool,
},
};
case 'UPDATE_TOOL_OPTION':
const { toolName, optionName, value } = action.payload;
return {
...state,
[toolbarSectionId]: {
...state[toolbarSectionId],
toolOptions: {
...state[toolbarSectionId].toolOptions,
[toolName]: state[toolbarSectionId].toolOptions[toolName].map(option =>
option.id === optionName ? { ...option, value } : option
),
},
},
};
case 'INITIALIZE_TOOL_OPTIONS':
// Initialize tool options for each toolbarSectionId
return {
...state,
[action.toolbarSectionId]: {
...state[action.toolbarSectionId],
toolOptions: action.payload,
},
};
default:
return state;
}
};
const ToolboxContext = createContext();
export const ToolboxProvider = ({ children }) => {
const [state, dispatch] = useReducer(toolboxReducer, initialState);
const handleToolSelect = (toolbarSectionId, toolName) => {
dispatch({
type: 'SET_ACTIVE_TOOL',
payload: { toolbarSectionId, activeTool: toolName },
});
};
const handleToolOptionChange = (toolbarSectionId, toolName, optionName, newValue) => {
dispatch({
type: 'UPDATE_TOOL_OPTION',
payload: { toolbarSectionId, toolName, optionName, value: newValue },
});
};
const initializeToolOptions = (toolbarSectionId, toolOptions) => {
dispatch({
type: 'INITIALIZE_TOOL_OPTIONS',
toolbarSectionId,
payload: toolOptions,
});
};
const api = { handleToolSelect, handleToolOptionChange, initializeToolOptions };
const value = { state, api };
return <ToolboxContext.Provider value={value}>{children}</ToolboxContext.Provider>;
};
/**
* Custom hook for accessing toolbox state and actions for a specific toolbar section.
* You can use this hook to access the state and actions for a specific toolbar section (
* defined by the toolbarSectionId) in your custom toolbar components. This hook
* helps to manage the state and actions for the tools and their options in the toolbar.
*/
export const useToolbox = toolbarSectionId => {
const context = useContext(ToolboxContext);
if (context === undefined) {
throw new Error('useToolbox must be used within a ToolboxProvider');
}
const { state, api } = context;
return {
state: state[toolbarSectionId] || { activeTool: null, toolOptions: {} },
api: {
handleToolSelect: toolName => api.handleToolSelect(toolbarSectionId, toolName),
handleToolOptionChange: (toolName, optionName, value) =>
api.handleToolOptionChange(toolbarSectionId, toolName, optionName, value),
initializeToolOptions: toolOptions =>
api.initializeToolOptions(toolbarSectionId, toolOptions),
},
};
};
@@ -0,0 +1,66 @@
export function toolboxReducer(state, action) {
let newState = { ...state };
const { payload } = action;
const { stateSyncService, buttonSectionId } = payload || {};
switch (action.type) {
case 'SET_ACTIVE_TOOL':
newState = {
...state,
activeTool: payload.activeTool,
};
break;
case 'UPDATE_TOOL_OPTION':
newState = {
...state,
toolOptions: {
...state.toolOptions,
[payload.toolName]: state.toolOptions[payload.toolName].map(option =>
option.id === payload.optionName ? { ...option, value: payload.value } : option
),
},
};
break;
case 'INITIALIZE_TOOL_OPTIONS':
// eslint-disable-next-line no-case-declarations
const newToolOptions = Object.keys(payload?.toolOptions || {}).reduce((acc, toolId) => {
const tool = payload.toolOptions[toolId];
if (state.toolOptions[toolId]) {
// Preserve existing options, potentially merging with new ones if necessary
acc[toolId] = state.toolOptions[toolId].map(existingOption => {
const initialOption = tool.find(option => option.id === existingOption.id);
return initialOption
? { ...initialOption, value: existingOption.value }
: existingOption;
});
} else {
acc[toolId] = tool;
}
return acc;
}, {});
newState = {
...state,
toolOptions: newToolOptions,
};
break;
default:
break;
}
// store the state in the stateSyncService
if (payload?.stateSyncService) {
const prevState = stateSyncService.getState()?.['uiStateStore']?.[buttonSectionId];
stateSyncService.store({
uiStateStore: {
...stateSyncService.getState()?.['uiStateStore'],
[buttonSectionId]: {
...prevState,
...newState,
},
},
});
}
return newState;
}
@@ -16,6 +16,7 @@ interface Viewport {
width: number;
height: number;
viewportLabel: any;
isReady: boolean;
}
interface Layout {
@@ -45,6 +46,7 @@ const DEFAULT_STATE: DefaultState = {
default: {
viewportId: 'default',
displaySetInstanceUIDs: [],
isReady: false,
viewportOptions: {
viewportId: 'default',
},
@@ -252,6 +254,7 @@ export function ViewportGridProvider({ children, service }) {
});
viewport.viewportLabel = getViewportLabel(viewports, viewport.viewportId);
viewport.isReady = false;
if (!viewport.viewportOptions.presentationIds) {
viewport.viewportOptions.presentationIds = ViewportGridService.getPresentationIds(
@@ -289,6 +292,25 @@ export function ViewportGridProvider({ children, service }) {
};
}
case 'VIEWPORT_IS_READY': {
const { viewportId, isReady } = action.payload;
const viewports = new Map(state.viewports);
const viewport = viewports.get(viewportId);
if (!viewport) {
return;
}
viewports.set(viewportId, {
...viewport,
isReady,
});
return {
...state,
viewports,
};
}
default:
return action.payload;
}
@@ -319,6 +341,25 @@ export function ViewportGridProvider({ children, service }) {
[dispatch]
);
const setViewportIsReady = useCallback(
(viewportId, isReady) => {
dispatch({
type: 'VIEWPORT_IS_READY',
payload: {
viewportId,
isReady,
},
});
},
[dispatch, viewportGridState]
);
const getGridViewportsReady = useCallback(() => {
const { viewports } = viewportGridState;
const readyViewports = Array.from(viewports.values()).filter(viewport => viewport.isReady);
return readyViewports.length === viewports.size;
}, [viewportGridState]);
const setLayout = useCallback(
({
layoutType,
@@ -382,6 +423,8 @@ export function ViewportGridProvider({ children, service }) {
onModeExit: reset,
set,
getNumViewportPanes,
setViewportIsReady,
getGridViewportsReady,
});
}
}, [
@@ -393,6 +436,8 @@ export function ViewportGridProvider({ children, service }) {
reset,
set,
getNumViewportPanes,
setViewportIsReady,
getGridViewportsReady,
]);
// run many of the calls through the service itself since we want to publish events
@@ -405,8 +450,11 @@ export function ViewportGridProvider({ children, service }) {
reset: () => service.reset(),
set: gridLayoutState => service.setState(gridLayoutState), // run it through the service itself since we want to publish events
getNumViewportPanes,
setViewportIsReady,
getGridViewportsReady,
getActiveViewportOptionByKey,
setViewportGridSizeChanged: props => service.setViewportGridSizeChanged(props),
publishViewportsReady: () => service.publishViewportsReady(),
};
return (
@@ -14,6 +14,8 @@ export { default as ViewportDialogProvider, useViewportDialog } from './Viewport
export { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './ViewportGridProvider';
export { useToolbox, ToolboxProvider } from './Toolbox/ToolboxContext';
export {
UserAuthenticationContext,
UserAuthenticationProvider,
+5 -6
View File
@@ -1,9 +1,3 @@
/** UTILS */
//import utils from './utils';
//export { utils };
/** CONTEXT/HOOKS */
// Export types - need to do as two lines due to a bug in babel
import * as Types from './types';
export {
@@ -31,6 +25,8 @@ export {
UserAuthenticationContext,
UserAuthenticationProvider,
useUserAuthentication,
useToolbox,
ToolboxProvider,
} from './contextProviders';
/** COMPONENTS */
@@ -86,6 +82,7 @@ export {
SegmentationGroupTable,
SidePanel,
SplitButton,
LegacySplitButton,
StudyBrowser,
StudyItem,
StudyListExpandedRow,
@@ -117,6 +114,8 @@ export {
WindowLevelMenuItem,
ImageScrollbar,
ViewportOverlay,
ToolSettings,
Toolbox,
InvestigationalUseDialog,
MeasurementItem,
} from './components';
+7 -1
View File
@@ -25,4 +25,10 @@ body {
height: 2.1rem;
}
/* disabled class - used for icons and divs based on context */
.ohif-disabled {
pointer-events: none;
cursor: not-allowed;
user-select: none;
opacity: 0.35;
}