feat(toolbar): new Toolbar to enable reactive state synchronization (#3983)
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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -10,6 +10,9 @@ window.config = {
|
||||
strictZSpacingForVolumeViewport: true,
|
||||
// filterQueryParam: false,
|
||||
defaultDataSourceName: 'e2e',
|
||||
investigationalUseDialog: {
|
||||
option: 'never',
|
||||
},
|
||||
dataSources: [
|
||||
{
|
||||
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -49,6 +49,8 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
|
||||
appConfig,
|
||||
});
|
||||
|
||||
servicesManager.setExtensionManager(extensionManager);
|
||||
|
||||
servicesManager.registerServices([
|
||||
UINotificationService.REGISTRATION,
|
||||
UIModalService.REGISTRATION,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -45,6 +45,7 @@ describe('Top level exports', () => {
|
||||
'pubSubServiceInterface',
|
||||
'PubSubService',
|
||||
'PanelService',
|
||||
'useToolbar',
|
||||
].sort();
|
||||
|
||||
const exports = Object.keys(OHIF).sort();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
|
After Width: | Height: | Size: 441 KiB |
|
After Width: | Height: | Size: 654 KiB |
@@ -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`:
|
||||
|
||||

|
||||
- `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:
|
||||
|
||||

|
||||
|
||||
```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
|
||||
|
||||

|
||||
|
||||
```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&autopause=0&player_id=0&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`
|
||||
|
||||

|
||||
|
||||
```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&autopause=0&player_id=0&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.
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
## 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 }],
|
||||
};
|
||||
```
|
||||
|
||||
|
||||

|
||||
@@ -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&autopause=0&player_id=0&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&autopause=0&player_id=0&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:
|
||||
|
||||

|
||||
|
||||
|
||||
```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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||