feat(actions): simplify action corner api through the toolbarService (#5033)

This commit is contained in:
Alireza authored and GitHub committed 2025-05-15 12:08:12 -04:00
1 parent f71b88e639
commit 7c15bb8901
246 files changed
+7930 -4270

No files matched your search

+1 -1
View File
@@ -53,7 +53,7 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
"@cornerstonejs/codec-openjpeg": "^1.2.4",
"@cornerstonejs/codec-openjph": "^2.4.5",
"@cornerstonejs/dicom-image-loader": "^3.12.2",
"@cornerstonejs/dicom-image-loader": "^3.15.1",
"@emotion/serialize": "^1.1.3",
"@ohif/core": "3.11.0-beta.32",
"@ohif/extension-cornerstone": "3.11.0-beta.32",
+2 -3
View File
@@ -13,6 +13,7 @@ import {
HotkeysManager,
ServiceProvidersManager,
SystemContextProvider,
ViewportRefsProvider,
} from '@ohif/core';
import {
ThemeWrapper as ThemeWrapperNext,
@@ -26,7 +27,6 @@ import {
ModalProvider,
ViewportDialogProvider,
UserAuthenticationProvider,
ViewportActionCornersProvider,
} from '@ohif/ui-next';
// Viewer Project
// TODO: Should this influence study list?
@@ -105,7 +105,6 @@ function App({
userAuthenticationService,
uiNotificationService,
customizationService,
viewportActionCornersService,
} = servicesManager.services;
const providers = [
@@ -114,6 +113,7 @@ function App({
[I18nextProvider, { i18n }],
[ThemeWrapperNext],
[SystemContextProvider, { commandsManager, extensionManager, hotkeysManager, servicesManager }],
[ViewportRefsProvider],
[ViewportGridProvider, { service: viewportGridService }],
[ViewportDialogProvider, { service: uiViewportDialogService }],
[CineProvider, { service: cineService }],
@@ -121,7 +121,6 @@ function App({
[TooltipProvider],
[DialogProvider, { service: uiDialogService, dialog: ManagedDialog }],
[ModalProvider, { service: uiModalService, modal: ModalNext }],
[ViewportActionCornersProvider, { service: viewportActionCornersService }],
[ShepherdJourneyProvider],
];
+1 -9
View File
@@ -4,13 +4,11 @@ import { ViewportGrid, ViewportPane } from '@ohif/ui-next';
import { useViewportGrid } from '@ohif/ui-next';
import EmptyViewport from './EmptyViewport';
import { useAppConfig } from '@state';
import { useViewportActionCornersWithGrid } from '../hooks';
function ViewerViewportGrid(props: withAppTypes) {
const { servicesManager, viewportComponents = [], dataSource, commandsManager } = props;
const [viewportGrid, viewportGridService] = useViewportGrid();
const [appConfig] = useAppConfig();
const { initializeViewportCorners } = useViewportActionCornersWithGrid();
const { layout, activeViewportId, viewports, isHangingProtocolLayout } = viewportGrid;
const { numCols, numRows } = layout;
@@ -419,12 +417,6 @@ function ViewerViewportGrid(props: withAppTypes) {
isHangingProtocolLayout={isHangingProtocolLayout}
onElementEnabled={evt => {
viewportGridService.setViewportIsReady(viewportId, true);
// Initialize the viewport action corners for this viewport
if (evt?.detail?.element) {
const elementRef = { current: evt.detail.element };
initializeViewportCorners(viewportId, elementRef, displaySets, commandsManager);
}
}}
/>
</div>
@@ -433,7 +425,7 @@ function ViewerViewportGrid(props: withAppTypes) {
}
return viewportPanes;
}, [viewports, activeViewportId, viewportComponents, dataSource, initializeViewportCorners]);
}, [viewports, activeViewportId, viewportComponents, dataSource]);
/**
* Loading indicator until numCols and numRows are gotten from the HangingProtocolService
+1 -2
View File
@@ -1,5 +1,4 @@
import useDebounce from './useDebounce';
import useSearchParams from './useSearchParams';
import useViewportActionCornersWithGrid from './useViewportActionCornersWithGrid';
export { useDebounce, useSearchParams, useViewportActionCornersWithGrid };
export { useDebounce, useSearchParams };
+1 -2
View File
@@ -1,5 +1,4 @@
import useViewportActionCornersWithGrid from './useViewportActionCornersWithGrid';
import useDebounce from './useDebounce';
import useSearchParams from './useSearchParams';
export { useViewportActionCornersWithGrid, useDebounce, useSearchParams };
export { useDebounce, useSearchParams };
@@ -1,112 +0,0 @@
import { useCallback, MutableRefObject, useRef } from 'react';
import { useViewportActionCorners, ViewportActionCornersLocations } from '@ohif/ui-next';
import { useSystem } from '@ohif/core';
/**
* Hook that manages viewport action corner components for all viewports in the grid
* @returns A function that can be called to initialize action corners for a viewport
*/
export default function useViewportActionCornersWithGrid() {
const [, api] = useViewportActionCorners();
const { servicesManager } = useSystem();
const { customizationService } = servicesManager.services;
// Keep a ref to track processed viewports to avoid duplicates
const processedViewports = useRef<Set<string>>(new Set());
// Map of customization keys to their corresponding enum values
const locationMap = {
'viewportActionMenu.topLeft': ViewportActionCornersLocations.topLeft,
'viewportActionMenu.topRight': ViewportActionCornersLocations.topRight,
'viewportActionMenu.bottomLeft': ViewportActionCornersLocations.bottomLeft,
'viewportActionMenu.bottomRight': ViewportActionCornersLocations.bottomRight,
};
// Function to process customizations for a viewport
const initializeViewportCorners = useCallback(
(
viewportId: string,
elementRef: MutableRefObject<HTMLDivElement>,
displaySets: any[],
commandsManager: any
) => {
if (!viewportId || !elementRef?.current) {
return;
}
// Prevent duplicate processing
if (processedViewports.current.has(viewportId)) {
return;
}
// Mark this viewport as processed
processedViewports.current.add(viewportId);
// Clear any existing components for this viewport
api.clear(viewportId);
// Process each location
Object.entries(locationMap).forEach(([locationKey, locationValue]) => {
const items = customizationService.getCustomization(locationKey);
if (!items || !items.length) {
return;
}
items.forEach(item => {
try {
if (typeof item.component === 'function') {
// Use the component renderer provided directly in the item
const component = item.component({
viewportId,
element: elementRef.current,
displaySets,
location: locationValue,
commandsManager,
});
if (component) {
api.addComponent({
viewportId,
id: item.id,
component,
location: locationValue,
indexPriority: item.indexPriority,
});
}
} else if (item.component) {
// Handle static components
api.addComponent({
viewportId,
id: item.id,
component: item.component,
location: locationValue,
indexPriority: item.indexPriority,
});
}
} catch (error) {
console.error(`Error adding component ${item.id} to viewport corner:`, error);
}
});
});
},
[api, customizationService]
);
// Cleanup function for unmounting viewports
const cleanupViewportCorners = useCallback(
(viewportId: string) => {
if (!viewportId) {
return;
}
// Remove from processed set
processedViewports.current.delete(viewportId);
// Clear from the store
api.clear(viewportId);
},
[api]
);
return { initializeViewportCorners, cleanupViewportCorners };
}
+2 -2
View File
@@ -83,10 +83,10 @@ export default function ModeRoute({
extensionManager.setActiveDataSource(dataSourceName);
}
const dataSource = extensionManager.getActiveDataSource()[0];
const dataSource = extensionManager.getActiveDataSourceOrNull();
// Only handling one route per mode for now
const route = mode.routes[0];
const route = mode.routes?.[0] ?? null;
useEffect(() => {
const loadExtensions = async () => {
+4 -4
View File
@@ -48,8 +48,8 @@ function modeFactory({ modeConfiguration }) {
// Init Default and SR ToolGroups
initToolGroups(extensionManager, toolGroupService, commandsManager);
toolbarService.addButtons([...toolbarButtons, ...moreTools]);
toolbarService.createButtonSection('primary', [
toolbarService.register([...toolbarButtons, ...moreTools]);
toolbarService.updateSection('primary', [
'measurementSection',
'Zoom',
'Pan',
@@ -61,7 +61,7 @@ function modeFactory({ modeConfiguration }) {
'moreToolsSection',
]);
toolbarService.createButtonSection('measurementSection', [
toolbarService.updateSection('measurementSection', [
'Length',
'Bidirectional',
'ArrowAnnotate',
@@ -73,7 +73,7 @@ function modeFactory({ modeConfiguration }) {
'LivewireContour',
]);
toolbarService.createButtonSection('moreToolsSection', [
toolbarService.updateSection('moreToolsSection', [
'Reset',
'rotate-right',
'flipHorizontal',
+2 -2
View File
@@ -37,8 +37,8 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
"@cornerstonejs/codec-openjpeg": "^1.2.4",
"@cornerstonejs/codec-openjph": "^2.4.5",
"@cornerstonejs/core": "^3.12.2",
"@cornerstonejs/dicom-image-loader": "^3.12.2",
"@cornerstonejs/core": "^3.15.1",
"@cornerstonejs/dicom-image-loader": "^3.15.1",
"@ohif/ui": "3.11.0-beta.32",
"cornerstone-math": "0.1.9",
"dicom-parser": "^1.8.21"
@@ -388,6 +388,10 @@ export default class ExtensionManager extends PubSubService {
return this.dataSourceMap[this._activeDataSourceName];
};
getActiveDataSourceOrNull = () => {
return this.dataSourceMap[this._activeDataSourceName]?.[0] ?? null;
};
/**
* Gets the data source definition for the given data source name.
* If no data source name is provided, the active data source definition is
+5
View File
@@ -0,0 +1,5 @@
export * from './useToolbar';
export * from './types';
export * from './useViewportRef';
export * from './useViewportSize';
export * from './useViewportMousePosition';
+27
View File
@@ -0,0 +1,27 @@
export interface ToolbarButtonActions {
// Lock/Unlock actions
lockItem: (itemId: string, viewportId?: string) => void;
unlockItem: (itemId: string, viewportId?: string) => void;
toggleLock: (itemId: string, viewportId?: string) => void;
isItemLocked: (itemId: string, viewportId?: string) => boolean;
// Visibility actions
showItem: (itemId: string, viewportId?: string) => void;
hideItem: (itemId: string, viewportId?: string) => void;
toggleVisibility: (itemId: string, viewportId?: string) => void;
isItemVisible: (itemId: string, viewportId?: string) => boolean;
// Open/Close actions (for menus)
openItem: (itemId: string, viewportId?: string) => void;
closeItem: (itemId: string, viewportId?: string) => void;
closeAllItems: (viewportId?: string) => void;
isItemOpen: (itemId: string, viewportId?: string) => boolean;
// Evaluation
evaluateButtonForViewport: (itemId: string, viewportId?: string) => any;
}
export interface ToolbarHookReturn extends ToolbarButtonActions {
toolbarButtons: any[]; // The display representation of toolbar buttons
onInteraction: (args: any) => void;
}
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react';
import { DisplaySet } from '../types';
import { useSystem } from '../';
/**
* Hook that listens for changes in the active viewport and its display sets.
* It returns the display sets associated with the active viewport.
@@ -8,7 +8,8 @@ import { DisplaySet } from '../types';
* @param servicesManager - Services manager instance
* @returns Array of display sets for the active viewport
*/
const useActiveViewportDisplaySets = ({ servicesManager }): DisplaySet[] => {
const useActiveViewportDisplaySets = (): DisplaySet[] => {
const { servicesManager } = useSystem();
const { displaySetService, viewportGridService } = servicesManager.services;
// Move this function outside useEffect and memoize it
const getDisplaySetsForViewport = useCallback(
+173 -7
View File
@@ -1,20 +1,26 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useState, useMemo } from 'react';
import { useSystem } from '../contextProviders/SystemProvider';
import { ToolbarHookReturn } from './types';
export function useToolbar({ buttonSection = 'primary' }: withAppTypes) {
export function useToolbar({ buttonSection = 'primary' }: withAppTypes): ToolbarHookReturn {
const { commandsManager, servicesManager } = useSystem();
const { toolbarService, viewportGridService } = servicesManager.services;
const { EVENTS } = toolbarService;
// Store all buttons returned by the toolbar service
const [toolbarButtons, setToolbarButtons] = useState(
toolbarService.getButtonSection(buttonSection as string)
toolbarService.getButtonSection(buttonSection as string).filter(Boolean)
);
// Store state of open/closed menu items
// Note: We keep this in local state to avoid re-evaluating the toolbar on every interaction
const [openItemIds, setOpenItemIds] = useState<Record<string, boolean>>({});
// Callback function for handling toolbar interactions
const onInteraction = useCallback(
args => {
args.event?.stopPropagation?.();
const viewportId = viewportGridService.getActiveViewportId();
const viewportId = args.viewportId || viewportGridService.getActiveViewportId();
const refreshProps = { viewportId };
const buttonProps = toolbarService.getButtonProps(args.itemId);
@@ -58,15 +64,17 @@ export function useToolbar({ buttonSection = 'primary' }: withAppTypes) {
buttonProps.commands = allCommands;
}
toolbarService.recordInteraction({ ...args, ...buttonProps }, { refreshProps });
},
[toolbarService, viewportGridService, toolbarButtons]
[toolbarService, viewportGridService]
);
// Effect to handle toolbar modification events
useEffect(() => {
const handleToolbarModified = () => {
setToolbarButtons(toolbarService.getButtonSection(buttonSection as string)?.filter(Boolean));
const buttons = toolbarService.getButtonSection(buttonSection as string)?.filter(Boolean);
setToolbarButtons(buttons);
};
const subs = [EVENTS.TOOL_BAR_MODIFIED, EVENTS.TOOL_BAR_STATE_MODIFIED].map(event => {
@@ -96,5 +104,163 @@ export function useToolbar({ buttonSection = 'primary' }: withAppTypes) {
return () => subscriptions.forEach(sub => sub.unsubscribe());
}, [viewportGridService, toolbarService]);
return { toolbarButtons, onInteraction };
// Action API for toolbar buttons
const actions = useMemo(() => {
return {
// Lock/Unlock actions
lockItem: (itemId: string, viewportId?: string) => {
const targetViewportId = viewportId || viewportGridService.getActiveViewportId();
const button = toolbarService.getButton(itemId);
if (button) {
// Set isLocked flag in button metadata
button.props = {
...button.props,
isLocked: true,
};
// Re-evaluate the button
toolbarService.refreshToolbarState({ viewportId: targetViewportId, itemId });
}
},
unlockItem: (itemId: string, viewportId?: string) => {
const targetViewportId = viewportId || viewportGridService.getActiveViewportId();
const button = toolbarService.getButton(itemId);
if (button) {
// Set isLocked flag in button metadata
button.props = {
...button.props,
isLocked: false,
};
// Re-evaluate the button
toolbarService.refreshToolbarState({ viewportId: targetViewportId, itemId });
}
},
toggleLock: (itemId: string, viewportId?: string) => {
const targetViewportId = viewportId || viewportGridService.getActiveViewportId();
const button = toolbarService.getButton(itemId);
if (button) {
// Toggle isLocked flag in button metadata
button.props = {
...button.props,
isLocked: !button.props.isLocked,
};
// Re-evaluate the button
toolbarService.refreshToolbarState({ viewportId: targetViewportId, itemId });
}
},
isItemLocked: (itemId: string, viewportId?: string): boolean => {
const button = toolbarService.getButton(itemId);
return button?.props?.isLocked === true;
},
// Visibility actions - controlled by evaluator functions in toolbar items
showItem: (itemId: string, viewportId?: string) => {
const targetViewportId = viewportId || viewportGridService.getActiveViewportId();
const button = toolbarService.getButton(itemId);
if (button) {
// Set isVisible flag in button metadata
button.props = {
...button.props,
isVisible: true,
};
// Re-evaluate the button
toolbarService.refreshToolbarState({ viewportId: targetViewportId, itemId });
}
},
hideItem: (itemId: string, viewportId?: string) => {
const targetViewportId = viewportId || viewportGridService.getActiveViewportId();
const button = toolbarService.getButton(itemId);
if (button) {
// Set isVisible flag in button metadata
button.props = {
...button.props,
isVisible: false,
};
// Re-evaluate the button
toolbarService.refreshToolbarState({ viewportId: targetViewportId, itemId });
}
},
toggleVisibility: (itemId: string, viewportId?: string) => {
const targetViewportId = viewportId || viewportGridService.getActiveViewportId();
const button = toolbarService.getButton(itemId);
if (button) {
// Toggle isVisible flag in button metadata
button.props = {
...button.props,
isVisible: button.props.isVisible === false ? true : false,
};
// Re-evaluate the button
toolbarService.refreshToolbarState({ viewportId: targetViewportId, itemId });
}
},
isItemVisible: (itemId: string, viewportId?: string): boolean => {
const button = toolbarService.getButton(itemId);
// If isVisible is explicitly false, return false; otherwise return true
return button?.props?.isVisible !== false;
},
// Open/Close actions - managed in local state for performance
openItem: (itemId: string, viewportId?: string) => {
setOpenItemIds(prev => {
// Close all other items
const updated = {};
// Then set the current item to open
updated[itemId] = true;
return updated;
});
},
closeItem: (itemId: string, viewportId?: string) => {
setOpenItemIds(prev => ({
...prev,
[itemId]: false,
}));
},
closeAllItems: (viewportId?: string) => {
setOpenItemIds({});
},
isItemOpen: (itemId: string, viewportId?: string): boolean => {
return openItemIds[itemId] === true;
},
};
}, [viewportGridService, toolbarService, openItemIds]);
if (!toolbarButtons?.length) {
return {
toolbarButtons: [],
onInteraction,
...actions,
};
}
// filter out buttons that are disabled and have hideWhenDisabled set to true
const filteredToolbarButtons = toolbarButtons.filter(button => {
const props = button.componentProps;
return props.visible !== false;
});
return {
toolbarButtons: filteredToolbarButtons,
onInteraction,
...actions,
};
}
@@ -0,0 +1,67 @@
import { useState, useEffect } from 'react';
import { useViewportRef } from './useViewportRef';
import { useViewportSize } from './useViewportSize';
interface MousePosition {
x: number;
y: number;
isInViewport: boolean;
relativeY: number; // Position as percentage from top (0-1)
isInBottomPercentage: (percentage: number) => boolean;
}
function useViewportMousePosition(viewportId: string): MousePosition {
const viewportRef = useViewportRef(viewportId);
const { height, clientRect } = useViewportSize(viewportId);
const [mousePosition, setMousePosition] = useState<MousePosition>({
x: 0,
y: 0,
isInViewport: false,
relativeY: 0,
isInBottomPercentage: (percentage: number) => false,
});
useEffect(() => {
if (!viewportRef.current) {
return;
}
const handleMouseMove = (event: MouseEvent) => {
if (!clientRect) {
return;
}
// Get mouse position relative to viewport
const x = event.clientX - clientRect.left;
const y = event.clientY - clientRect.top;
const isInViewport = x >= 0 && x <= clientRect.width && y >= 0 && y <= clientRect.height;
const relativeY = Math.max(0, Math.min(1, y / height));
const isInBottomPercentage = (percentage: number) => {
return relativeY >= 1 - percentage / 100;
};
setMousePosition({
x,
y,
isInViewport,
relativeY,
isInBottomPercentage,
});
};
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
};
}, [viewportRef, height, clientRect]);
return mousePosition;
}
export default useViewportMousePosition;
export { useViewportMousePosition };
+58
View File
@@ -0,0 +1,58 @@
import React, { createContext, useContext, useRef } from 'react';
type ViewportRefsContextType = {
registerViewport: (viewportId: string, element: HTMLElement) => void;
unregisterViewport: (viewportId: string) => void;
getViewportElement: (viewportId: string) => HTMLElement | null;
viewportRefs: Map<string, HTMLElement>;
};
// comment
const ViewportRefsContext = createContext<ViewportRefsContextType | undefined>(undefined);
export const ViewportRefsProvider = ({ children }: { children: React.ReactNode }) => {
const viewportRefsRef = useRef<Map<string, HTMLElement>>(new Map());
const registerViewport = (viewportId: string, element: HTMLElement) => {
viewportRefsRef.current.set(viewportId, element);
};
const unregisterViewport = (viewportId: string) => {
viewportRefsRef.current.delete(viewportId);
};
const getViewportElement = (viewportId: string): HTMLElement | null => {
return viewportRefsRef.current.get(viewportId) || null;
};
const contextValue: ViewportRefsContextType = {
registerViewport,
unregisterViewport,
getViewportElement,
viewportRefs: viewportRefsRef.current,
};
return React.createElement(ViewportRefsContext.Provider, { value: contextValue }, children);
};
export const useViewportRefs = () => {
const context = useContext(ViewportRefsContext);
if (context === undefined) {
throw new Error('useViewportRefs must be used within a ViewportRefsProvider');
}
return context;
};
export const useViewportRef = (viewportId: string) => {
const { registerViewport, unregisterViewport, getViewportElement } = useViewportRefs();
const ref = {
current: getViewportElement(viewportId),
register: (element: HTMLElement) => registerViewport(viewportId, element),
unregister: () => unregisterViewport(viewportId),
};
return ref;
};
@@ -0,0 +1,97 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useViewportRef } from './';
interface ViewportSize {
width: number;
height: number;
offsetLeft: number;
offsetTop: number;
clientRect: DOMRect | null;
isVisible: boolean;
}
/**
* Hook that provides viewport size dimensions and monitors for changes
* @param viewportId - The ID of the viewport to monitor
* @returns ViewportSize object containing width, height, and visibility info
*/
function useViewportSize(viewportId: string): ViewportSize {
const viewportElementRef = useViewportRef(viewportId);
const [size, setSize] = useState<ViewportSize>({
width: 0,
height: 0,
offsetLeft: 0,
offsetTop: 0,
clientRect: null,
isVisible: false,
});
// Update viewport dimensions
const updateViewportSize = useCallback(() => {
if (!viewportElementRef?.current) {
return;
}
const element = viewportElementRef.current;
const clientRect = element.getBoundingClientRect();
const newWidth = clientRect.width;
const newHeight = clientRect.height;
const newOffsetLeft = element.offsetLeft;
const newOffsetTop = element.offsetTop;
const newIsVisible = newWidth > 0 && newHeight > 0;
setSize(prevSize => {
if (
prevSize.width === newWidth &&
prevSize.height === newHeight &&
prevSize.offsetLeft === newOffsetLeft &&
prevSize.offsetTop === newOffsetTop &&
prevSize.isVisible === newIsVisible
) {
return prevSize;
}
return {
width: newWidth,
height: newHeight,
offsetLeft: newOffsetLeft,
offsetTop: newOffsetTop,
clientRect,
isVisible: newIsVisible,
};
});
}, [viewportElementRef]);
useEffect(() => {
if (!viewportId || !viewportElementRef?.current) {
return;
}
updateViewportSize();
const resizeObserver = new ResizeObserver(() => {
updateViewportSize();
});
resizeObserver.observe(viewportElementRef.current);
window.addEventListener('resize', updateViewportSize);
return () => {
window.removeEventListener('resize', updateViewportSize);
if (viewportElementRef.current) {
resizeObserver.unobserve(viewportElementRef.current);
}
resizeObserver.disconnect();
};
}, [viewportId, viewportElementRef, updateViewportSize]);
const memoizedSize = useMemo(() => size, [size]);
return memoizedSize;
}
export default useViewportSize;
export { useViewportSize };
+4 -3
View File
@@ -2,6 +2,7 @@ import { ExtensionManager, MODULE_TYPES } from './extensions';
import { ServiceProvidersManager, ServicesManager } from './services';
import classes, { CommandsManager, HotkeysManager } from './classes';
import { SystemContextProvider, useSystem } from './contextProviders/SystemProvider';
import { ViewportRefsProvider } from './hooks/useViewportRef';
import DICOMWeb from './DICOMWeb';
import errorHandler from './errorHandler.js';
@@ -13,7 +14,6 @@ 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,
UIDialogService,
@@ -42,6 +42,8 @@ import { DisplaySetMessage, DisplaySetMessageList } from './services/DisplaySetS
import IWebApiDataSource from './DataSources/IWebApiDataSource';
import useActiveViewportDisplaySets from './hooks/useActiveViewportDisplaySets';
export * from './hooks';
const hotkeys = {
...utils.hotkeys,
defaults: { hotkeyBindings: defaults.hotkeyBindings },
@@ -86,7 +88,6 @@ const OHIF = {
pubSubServiceInterface,
PubSubService,
PanelService,
useToolbar,
useActiveViewportDisplaySets,
WorkflowStepsService,
StudyPrefetcherService,
@@ -101,6 +102,7 @@ export {
ServicesManager,
ServiceProvidersManager,
SystemContextProvider,
ViewportRefsProvider,
//
defaults,
utils,
@@ -137,7 +139,6 @@ export {
WorkflowStepsService,
StudyPrefetcherService,
useSystem,
useToolbar,
useActiveViewportDisplaySets,
};
@@ -2,13 +2,68 @@ import { CommandsManager } from '../../classes';
import { ExtensionManager } from '../../extensions';
import { PubSubService } from '../_shared/pubSubServiceInterface';
import type { RunCommand } from '../../types/Command';
import { Button, ButtonProps, EvaluateFunction, EvaluatePublic, NestedButtonProps } from './types';
import { Button, ButtonProps, EvaluateFunction, EvaluatePublic } from './types';
const EVENTS = {
TOOL_BAR_MODIFIED: 'event::toolBarService:toolBarModified',
TOOL_BAR_STATE_MODIFIED: 'event::toolBarService:toolBarStateModified',
};
/**
* Predefined toolbar sections used throughout the application
*/
export const TOOLBAR_SECTIONS = {
/**
* Main toolbar
*/
primary: 'primary',
/**
* Secondary toolbar
*/
secondary: 'secondary',
/**
* Viewport action menu sections
*/
viewportActionMenu: {
topLeft: 'viewportActionMenu.topLeft',
topRight: 'viewportActionMenu.topRight',
bottomLeft: 'viewportActionMenu.bottomLeft',
bottomRight: 'viewportActionMenu.bottomRight',
topMiddle: 'viewportActionMenu.topMiddle',
bottomMiddle: 'viewportActionMenu.bottomMiddle',
leftMiddle: 'viewportActionMenu.leftMiddle',
rightMiddle: 'viewportActionMenu.rightMiddle',
},
/**
* Measurement tools section
*/
measurementSection: 'measurementSection',
/**
* More tools section
*/
moreToolsSection: 'moreToolsSection',
/**
* Advanced rendering controls section
*/
advancedRenderingControlsSection: 'advancedRenderingControlsSection',
};
export enum ButtonLocation {
TopLeft = 0,
TopMiddle = 1,
TopRight = 2,
LeftMiddle = 3,
RightMiddle = 4,
BottomLeft = 5,
BottomMiddle = 6,
BottomRight = 7,
}
export default class ToolbarService extends PubSubService {
public static REGISTRATION = {
name: 'toolbarService',
@@ -18,6 +73,13 @@ export default class ToolbarService extends PubSubService {
},
};
/**
* Access to predefined toolbar sections for autocomplete support
*/
public get sections() {
return TOOLBAR_SECTIONS;
}
public static createButton(options: {
id: string;
label: string;
@@ -130,7 +192,7 @@ export default class ToolbarService extends PubSubService {
* @param buttons - The buttons to be added.
* @param replace - Flag indicating if any existing button with the same id as one being added should be replaced
*/
public addButtons(buttons: Button[], replace: boolean = false): void {
public register(buttons: Button[], replace: boolean = false): void {
buttons.forEach(button => {
if (replace || !this.state.buttons[button.id]) {
if (!button.props) {
@@ -231,7 +293,8 @@ export default class ToolbarService extends PubSubService {
* which buttons to evaluate based on the props
*/
public refreshToolbarState(refreshProps) {
const buttons = this.state.buttons;
const originalButtons = this.state.buttons;
const updatedButtons = { ...originalButtons };
const evaluationResults = new Map();
const evaluateButtonProps = (button, props, refreshProps) => {
@@ -239,6 +302,7 @@ export default class ToolbarService extends PubSubService {
const { disabled, disabledText, className, isActive } = evaluationResults.get(button.id);
return { ...props, disabled, disabledText, className, isActive };
} else {
const evaluateProps = props.evaluateProps;
const evaluated =
typeof props.evaluate === 'function'
? props.evaluate({ ...refreshProps, button })
@@ -247,6 +311,7 @@ export default class ToolbarService extends PubSubService {
...props,
...evaluated,
disabled: evaluated?.disabled || false,
visible: evaluateProps?.hideWhenDisabled && evaluated?.disabled ? false : true,
className: evaluated?.className || '',
isActive: evaluated?.isActive, // isActive will be undefined for buttons without this prop
};
@@ -255,7 +320,14 @@ export default class ToolbarService extends PubSubService {
}
};
Object.values(buttons).forEach(button => {
const updatedIds = new Set();
Object.values(originalButtons).forEach(button => {
// Note: do not re-evaluate buttons that have already been evaluated
// this will result in inconsistencies in the toolbar state
if (updatedIds.has(button.id)) {
return;
}
const hasSection = (button.props as NestedButtonProps)?.buttonSection;
if (!hasSection) {
@@ -263,10 +335,12 @@ export default class ToolbarService extends PubSubService {
const buttonProps = button.props as ButtonProps;
const updatedProps = evaluateButtonProps(button, buttonProps, refreshProps);
buttons[button.id] = {
updatedButtons[button.id] = {
...button,
props: updatedProps,
};
updatedIds.add(button.id);
} else {
let buttonProps = button.props as NestedButtonProps;
const { evaluate: groupEvaluate } = buttonProps;
@@ -288,17 +362,27 @@ export default class ToolbarService extends PubSubService {
}
toolButtonIds.forEach(buttonId => {
const button = buttons[buttonId];
const button = originalButtons[buttonId];
if (!button) {
return;
}
if (updatedIds.has(buttonId)) {
return;
}
const updatedProps = evaluateButtonProps(button, button.props, refreshProps);
buttons[buttonId] = {
updatedButtons[buttonId] = {
...button,
props: updatedProps,
};
updatedIds.add(buttonId);
});
}
});
this.setButtons(buttons);
this.setButtons(updatedButtons);
return this.state;
}
@@ -373,7 +457,7 @@ export default class ToolbarService extends PubSubService {
* @param {string} key - The key of the button section.
* @param {Array} buttons - The buttons to be added to the section.
*/
createButtonSection(key, buttons) {
updateSection(key, buttons) {
if (this.state.buttonSections[key]) {
this.state.buttonSections[key].push(
...buttons.filter(
@@ -443,7 +527,7 @@ export default class ToolbarService extends PubSubService {
}
const { id, uiType } = btn;
const { groupId } = btn.props as NestedButtonProps;
const { buttonSection } = btn.props;
const buttonTypes = this._getButtonUITypes();
@@ -457,7 +541,7 @@ export default class ToolbarService extends PubSubService {
return;
}
!groupId ? this.handleEvaluate(btn.props) : this.handleEvaluateNested(btn.props);
!buttonSection ? this.handleEvaluate(btn.props) : this.handleEvaluateNested(btn.props);
const { id: buttonId, props: componentProps } = btn;
@@ -587,6 +671,7 @@ export default class ToolbarService extends PubSubService {
return evaluateFunction;
});
const evaluateProps = props.evaluate;
props.evaluate = args => {
const results = evaluators.map(evaluator => evaluator(args)).filter(Boolean);
@@ -607,6 +692,8 @@ export default class ToolbarService extends PubSubService {
return mergedResult;
};
props.evaluateProps = evaluateProps;
return;
}
@@ -627,7 +714,9 @@ export default class ToolbarService extends PubSubService {
const { name, ...options } = evaluate;
const evaluateFunction = this._evaluateFunction[name];
if (evaluateFunction) {
const evaluateProps = props.evaluate;
props.evaluate = args => evaluateFunction({ ...args, ...options });
props.evaluateProps = evaluateProps;
return;
}
@@ -645,4 +734,56 @@ export default class ToolbarService extends PubSubService {
this.state.buttonSections[buttonSection] = [];
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { ...this.state });
}
/**
* Checks if a button exists in any toolbar section.
*
* @param buttonId - The button ID to check for
* @returns True if the button exists in any section, false otherwise
*/
isInAnySection(buttonId: string): boolean {
if (!buttonId) {
return false;
}
// Check all sections to see if the button ID exists in any of them
return Object.values(this.state.buttonSections).some(
section => Array.isArray(section) && section.includes(buttonId)
);
}
/**
* Returns the alignment and side for a specific viewport corner location.
* Used for menu positioning based on the corner location.
*
* @param location - The viewport corner location
* @returns An object with align and side properties
*/
public getAlignAndSide(location: ButtonLocation | string): {
align: 'start' | 'end' | 'center';
side: 'top' | 'bottom' | 'left' | 'right';
} {
const locationNumber = Number(location);
switch (locationNumber) {
case ButtonLocation.TopLeft: // Enum 0, Original 0 (topLeft)
return { align: 'start', side: 'bottom' };
case ButtonLocation.TopMiddle: // Enum 1, Original 4 (topMiddle)
return { align: 'center', side: 'bottom' };
case ButtonLocation.TopRight: // Enum 2, Original 1 (topRight)
return { align: 'end', side: 'bottom' };
case ButtonLocation.LeftMiddle: // Enum 3, Original 6 (leftMiddle)
return { align: 'start', side: 'right' };
case ButtonLocation.RightMiddle: // Enum 4, Original 7 (rightMiddle)
return { align: 'end', side: 'left' };
case ButtonLocation.BottomLeft: // Enum 5, Original 2 (bottomLeft)
return { align: 'start', side: 'top' };
case ButtonLocation.BottomMiddle: // Enum 6, Original 5 (bottomMiddle)
return { align: 'center', side: 'top' };
case ButtonLocation.BottomRight: // Enum 7, Original 3 (bottomRight)
return { align: 'end', side: 'top' };
default:
// Default to TopLeft behavior if an unexpected value is passed.
return { align: 'start', side: 'bottom' };
}
}
}
@@ -1,3 +1,3 @@
import ToolbarService from './ToolbarService';
import ToolbarService, { TOOLBAR_SECTIONS } from './ToolbarService';
export default ToolbarService;
export { TOOLBAR_SECTIONS };
@@ -1,6 +1,22 @@
import type { RunCommand } from '../../types/Command';
import React from 'react';
/**
* Type definitions for toolbar sections
*/
export type ToolbarSections = {
primary: string;
secondary: string;
viewportActionMenu: {
topLeft: string;
topRight: string;
bottomLeft: string;
bottomRight: string;
};
measurementSection: string;
moreToolsSection: string;
};
export type EvaluatePublic =
| string
| EvaluateFunction
@@ -137,7 +137,7 @@ class WorkflowStepsService extends PubSubService {
toUse.forEach(({ buttonSection, buttons }) => {
toolbarService.clearButtonSection(buttonSection);
toolbarService.createButtonSection(buttonSection, buttons);
toolbarService.updateSection(buttonSection, buttons);
});
}
+10
View File
@@ -35,9 +35,19 @@ export type DisplaySet = {
*/
renderedUrl?: string;
/**
* The instance UID of the display set that this display set references.
* This is used to determine if the display set is a referenced display set.
* It usually is for SEG, RTSTRUCT, etc.
*/
referencedDisplaySetInstanceUID?: string;
SeriesDate?: string;
SeriesTime?: string;
instance?: InstanceMetadata;
isHydrated?: boolean;
isRehydratable?: boolean;
};
export type DisplaySetSeriesMetadataInvalidatedEvent = {
@@ -0,0 +1,72 @@
---
sidebar_position: 4
sidebar_label: Commands
summary: Migration guide for OHIF 3.11's different commands
---
## updateStoredPositionPresentation
now uses displaySetInstanceUIDs instead of displaySetInstanceUID as a parameter.
## `loadSRMeasurements` Command
**Key Changes:**
* The `loadSRMeasurements` command, previously part of the `@ohif/extension-cornerstone-dicom-sr` extension, has been **removed**.
* Its functionality of hydrating a Structured Report (SR) and displaying its referenced series in a viewport is now primarily handled by the new `hydrateSecondaryDisplaySet` command available in the `@ohif/extension-cornerstone` extension.
* The `hydrateStructuredReport` command (from `@ohif/extension-cornerstone-dicom-sr`) now solely focuses on hydrating the SR and returning its data, without directly manipulating viewports.
**Migration Steps:**
If you were previously using the `loadSRMeasurements` command to load and display SR measurements, you should update your code to use the `hydrateSecondaryDisplaySet` command.
1. **Identify `loadSRMeasurements` Usage:**
Locate where your code calls `commandsManager.runCommand('loadSRMeasurements', ...)`.
2. **Update to `hydrateSecondaryDisplaySet`:**
Replace the call to `loadSRMeasurements` with `hydrateSecondaryDisplaySet`. You will need to pass the full `displaySet` object for the SR and the target `viewportId`.
```diff
- // Old way: using loadSRMeasurements
- commandsManager.runCommand('loadSRMeasurements', {
- displaySetInstanceUID: srDisplaySetInstanceUID,
- // viewportId was implicitly the active one or not directly specifiable here
- });
-
+ // New way: using hydrateSecondaryDisplaySet
+ const { displaySetService, viewportGridService } = servicesManager.services;
+
+ // 1. Get the SR displaySet object
+ const srDisplaySet = displaySetService.getDisplaySetByUID(srDisplaySetInstanceUID);
+
+ // 2. Determine the target viewportId (e.g., active viewport)
+ const viewportId = viewportGridService.getActiveViewportId(); // Or your specific viewportId
+
+ if (srDisplaySet && viewportId) {
+ commandsManager.runCommand('hydrateSecondaryDisplaySet', {
+ displaySet: srDisplaySet,
+ viewportId: viewportId,
+ });
+ } else {
+ console.warn('SR DisplaySet or ViewportId not found, cannot hydrate.');
+ }
```
**Explanation:**
* The `loadSRMeasurements` command was responsible for both hydrating the SR (getting its measurement data and referenced series UIDs) and then updating the viewport to show the referenced series.
* The new `hydrateSecondaryDisplaySet` command, when given an SR `displaySet` (`displaySet.Modality === 'SR'`), will:
1. Internally call the `hydrateStructuredReport` command to parse the SR and get its details (including `SeriesInstanceUIDs` of referenced images).
2. Then, it will automatically find the corresponding image display sets for those `SeriesInstanceUIDs`.
3. Finally, it will update the specified `viewportId` to display the primary referenced image series.
* This change centralizes the logic for hydrating secondary display sets (like SR, SEG, RTSTRUCT) and updating viewports into the `hydrateSecondaryDisplaySet` command within the core Cornerstone extension.
**Note on UI/Button Changes:**
The UI button typically associated with "Load SR" (often seen in viewport corners or specific contexts) has also been refactored. The hydration of SRs is now often triggered by:
* The `TrackedMeasurementsContext` if the `@ohif/extension-measurement-tracking` is in use.
* The new `ModalityLoadBadge` component, which can appear in viewports containing SR, SEG, or RTSTRUCT display sets, offering a "LOAD" action that calls `hydrateSecondaryDisplaySet`.
If you had custom UI invoking `loadSRMeasurements`, you'll need to adapt it to call `hydrateSecondaryDisplaySet` as described above.
@@ -0,0 +1,85 @@
---
title: Hydration
sidebar_position: 3
sidebar_label: Hydration
summary: Migration guide for OHIF 3.11's hydration system changes, including the transition to a centralized hydration dialog and command-based hydration for secondary display sets.
---
## Update Hydration Logic:
* The `promptHydrateSEG` and `promptHydrateRT` functions have been updated to use the generic `utils.promptHydrationDialog` from `@ohif/extension-cornerstone`.
* The actual hydration is now often triggered by the `hydrateSecondaryDisplaySet` command.
*Example: `promptHydrateRT` change*
```diff
// extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts
- const RESPONSE = {
- NO_NEVER: -1,
- CANCEL: 0,
- HYDRATE_SEG: 5,
- };
+ import { utils, Types } from '@ohif/extension-cornerstone';
function promptHydrateRT({
servicesManager,
rtDisplaySet,
viewportId,
preHydrateCallbacks,
hydrateRTDisplaySet,
-}: withAppTypes) {
- const { uiViewportDialogService, customizationService } = servicesManager.services;
- // ... lots of old promise and dialog logic
- return new Promise(async function (resolve, reject) {
- // ...
- });
-}
-
-function _askHydrate(
- // ...
-) {
- // ...
-}
+}: {
+ servicesManager: AppTypes.ServicesManager;
+ rtDisplaySet: AppTypes.DisplaySet;
+ viewportId: string;
+ preHydrateCallbacks?: Types.HydrationCallback[];
+ hydrateRTDisplaySet: Types.HydrationCallback;
+}) {
+ return utils.promptHydrationDialog({
+ servicesManager,
+ viewportId,
+ displaySet: rtDisplaySet,
+ preHydrateCallbacks,
+ hydrateCallback: hydrateRTDisplaySet,
+ type: 'RTSTRUCT',
+ });
}
```
The `hydrateRTDisplaySet` callback passed to this function would now typically involve the `hydrateSecondaryDisplaySet` command.
```diff
// extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx
useEffect(() => {
if (rtIsLoading) {
return;
}
promptHydrateRT({
servicesManager,
viewportId,
rtDisplaySet,
- preHydrateCallbacks: [storePresentationState],
- hydrateRTDisplaySet,
- }).then(isHydrated => {
- if (isHydrated) {
- setIsHydrated(true);
- }
+ hydrateRTDisplaySet: async () => {
+ return commandsManager.runCommand('hydrateSecondaryDisplaySet', {
+ displaySet: rtDisplaySet,
+ viewportId,
+ });
+ },
});
- }, [servicesManager, viewportId, rtDisplaySet, rtIsLoading]);
+ }, [servicesManager, viewportId, rtDisplaySet, rtIsLoading, commandsManager]);
```
@@ -6,17 +6,3 @@ sidebar_label: 3.10 -> 3.11 beta
# Migration Guide
This guide provides information about migrating from OHIF version 3.10 to version 3.11.
## General
`viewportActionMenu.segmentationOverlay` is renamed to `viewportActionMenu.dataOverlay`
as it handles now both segmentation and data overlay.
## Viewport Action Menu Customization
The structure for defining viewport action menu customizations has changed. See the [Viewport Action Menu](./viewport-action-menu.md) migration guide for details.
## updateStoredPositionPresentation
now uses displaySetInstanceUIDs instead of displaySetInstanceUID.
@@ -0,0 +1,153 @@
---
sidebar_position: 2
sidebar_label: Toolbar Service
summary: Migration guide for OHIF 3.11's toolbar service changes, including the transition from `ViewportActionCornersService` to `ToolbarService`
---
**Key Changes:**
* **`ViewportActionCornersService` Removed:** The `ViewportActionCornersService` and its associated provider (`ViewportActionCornersProvider`) and hook (`useViewportActionCorners`) have been removed. Functionality for viewport corner items is now integrated into the `ToolbarService` and standard toolbar components.
* **Viewport Corner Items as Toolbar Buttons:** Items previously managed by `ViewportActionCornersService` are now configured as regular toolbar buttons. They are assigned to specific toolbar sections (e.g., `viewportActionMenu.topLeft`) and rendered using `Toolbar` components within the viewport corners.
* **`ToolbarService` API Updates:**
* `ToolbarService.addButtons()` has been renamed to `ToolbarService.register()` to better reflect its purpose of registering button definitions rather than just adding them.
* `ToolbarService.createButtonSection()` has been renamed to `ToolbarService.updateSection()` to better reflect that it is not about creating new sections but updating existing ones.
* `ToolbarService` now has a `sections` property (e.g., `toolbarService.sections.viewportActionMenu.topLeft`) providing predefined section names.
* **Enhanced `useToolbar` Hook:**
* The `useToolbar` hook now returns additional state management functions for toolbar items:
* `openItem`, `closeItem`, `isItemOpen` (for managing dropdown/popover states).
* `lockItem`, `unlockItem`, `toggleLock`, `isItemLocked`.
* `showItem`, `hideItem`, `toggleVisibility`, `isItemVisible`.
* The `onInteraction` callback now receives `itemId` and `viewportId`.
* **Toolbar Button Configuration:**
* The `groupId` prop in button configurations (e.g., for `ohif.toolButtonList`, `ohif.toolBoxButtonGroup`) is generally replaced by directly using `buttonSection` to define the set of buttons.
* Button `evaluate` functions can now leverage `evaluateProps.hideWhenDisabled` to automatically hide a button when it's disabled.
* **New UI Components & Hooks for Viewport Corners:**
* Specialized components like `ModalityLoadBadge`, `NavigationComponent`, `TrackingStatus`, `ViewportDataOverlayMenuWrapper`, `ViewportOrientationMenuWrapper`, `WindowLevelActionMenuWrapper` are now used as toolbar buttons, typically in viewport action menu sections.
* `useViewportHover` hook can be used to determine if a viewport is hovered or active, controlling the visibility of corner toolbars.
* **`IconPresentationProvider`:** A new `IconPresentationProvider` and `useIconPresentation` hook have been introduced to standardize icon sizing and styling within toolbars and related components.
* **Legacy Toolbar Components Removed:** `ToolbarSplitButtonWithServicesLegacy` and `ToolbarButtonGroupWithServicesLegacy` have been removed.
**Migration Steps:**
1. **Update `ToolbarService` Method Calls:**
* Replace all instances of `toolbarService.addButtons(...)` with `toolbarService.register(...)`.
* Replace all instances of `toolbarService.createButtonSection(...)` with `toolbarService.updateSection(...)`.
```diff
// Before
- toolbarService.addButtons(toolbarButtons);
- toolbarService.createButtonSection('primary', ['Zoom', 'Pan']);
// After
+ toolbarService.register(toolbarButtons);
+ toolbarService.updateSection('primary', ['Zoom', 'Pan']);
```
2. **Migrate Viewport Action Corner Items:**
* Remove any direct usage of the old `ViewportActionCornersService`, `useViewportActionCorners`, or `ViewportActionCornersProvider`.
* Define your viewport corner items (like orientation menu, W/L menu, data overlay menu) as standard toolbar buttons using `toolbarService.register()`.
* Assign these buttons to the new dedicated viewport action menu sections. You can access these section names via `toolbarService.sections.viewportActionMenu.<location>`, e.g., `toolbarService.sections.viewportActionMenu.topLeft`.
```diff
// Before: Customization in viewportActionMenuCustomizations.ts (now deleted)
// or direct use of ViewportActionCornersService.addComponent
- // Example: viewportActionCornersService.addComponent({ viewportId, id: 'orientationMenu', component: MyOrientationMenu, location: 'topLeft' });
// After: In your mode's onModeEnter or similar setup
+ const myViewportCornerButtons = [
+ {
+ id: 'orientationMenu',
+ uiType: 'ohif.orientationMenu', // Or your custom component registered as a UI type
+ props: { /* ... props for your component ... */ }
+ },
+ // ... other corner buttons
+ ];
+ toolbarService.register(myViewportCornerButtons);
+ toolbarService.updateSection(
+ toolbarService.sections.viewportActionMenu.topLeft,
+ ['orientationMenu', /* other button IDs */]
+ );
```
* The `OHIFViewportActionCorners.tsx` component now internally uses `Toolbar` components for each corner, which are populated by these sections.
* For custom components that act as menus (e.g., popovers), use the `onOpen`, `onClose`, `isOpen` props passed down by the `Toolbar` component (which get these from `useToolbar`).
```diff
// Before: Custom component might have managed its own open state
- // const [isMenuOpen, setIsMenuOpen] = useState(false);
- // const handleOpenChange = (open) => setIsMenuOpen(open);
// After: Custom component receives isOpen, onOpen, onClose from Toolbar
+ function MyCustomMenuButton({ isOpen, onOpen, onClose, ...rest }) {
+ const handleOpenChange = (openState: boolean) => {
+ if (openState) {
+ onOpen?.();
+ } else {
+ onClose?.();
+ }
+ };
+
+ return (
+ <Popover open={isOpen} onOpenChange={handleOpenChange}>
+ {/* ... PopoverTrigger and PopoverContent ... */}
+ </Popover>
+ );
+ }
```
3. **Adapt Toolbar Button and Component Configurations:**
* For `ohif.toolButtonList` or `ohif.toolBoxButtonGroup` (and their wrappers), the `groupId` prop is no longer the primary way to define the set of buttons. Instead, ensure the `buttonSection` prop correctly points to the section name containing the desired buttons. The `id` prop on these wrapper components should be unique for the component instance.
```diff
// Before
- {
- id: 'MeasurementTools',
- uiType: 'ohif.toolButtonList',
- props: {
- buttonSection: 'measurementSection',
- groupId: 'MeasurementTools', // groupId often matched buttonSection
- },
- },
// After
+ {
+ id: 'MeasurementTools', // This is the ID of the ToolButtonList/ToolBox component itself
+ uiType: 'ohif.toolButtonList',
+ props: {
+ // This section contains the actual tool buttons (e.g., Length, Bidirectional)
+ buttonSection: 'measurementSection',
+ },
+ },
```
* Update wrappers like `ToolBoxButtonGroupWrapper` and `ToolButtonListWrapper`:
* The `groupId` prop is replaced by `id` (which is the ID of the wrapper button itself).
* The `onInteraction` callback in these wrappers now provides `id` (the wrapper's ID) instead of `groupId`.
* If you have custom `evaluate` functions, you can now use `evaluateProps: { hideWhenDisabled: true }` in your button definition to automatically hide the button if it evaluates to disabled.
5. **Adopt `IconPresentationProvider` (Optional but Recommended):**
* For consistent icon styling across your application's toolbars, wrap a high-level component (like your main `Header` or layout component) with `<IconPresentationProvider size="yourDefaultSize">`.
* Custom tool button components can then use the `useIconPresentation` hook to get appropriate class names for icons or a pre-styled `IconContainer`.
```diff
// In your main App.tsx or Header.tsx
+ import { IconPresentationProvider, ToolButton } from '@ohif/ui-next';
// ...
+ <IconPresentationProvider
+ size="large" // Or "medium", "small", "tiny", or a number
+ IconContainer={ToolButton} // Optional: default is Button
+ containerProps={{ variant: 'primary', className: 'custom-container-class' }} // Optional
+ >
{/* Your Header content including Toolbars */}
+ </IconPresentationProvider>
// In a custom tool button using icons
+ import { useIconPresentation, Icons } from '@ohif/ui-next';
+ function MyCustomToolButton({ iconName }) {
+ const { className: iconClassName } = useIconPresentation();
+ return <button><Icons.ByName name={iconName} className={iconClassName} /></button>;
+ }
```
6. **Remove Legacy Component Usage:**
* Replace any usage of `ToolbarSplitButtonWithServicesLegacy` and `ToolbarButtonGroupWithServicesLegacy` with the newer patterns, typically by configuring individual buttons and using `ToolButtonList` or `ButtonGroup` from `@ohif/ui-next` directly, driven by `useToolbar`.
@@ -0,0 +1,60 @@
---
sidebar_position: 5
sidebar_label: UI
summary: Migration guide for OHIF 3.11's UI changes, including the transition from `ViewportActionCornersService` to `ToolbarService` and the introduction of `useToolbar` hook.
---
## New useIconPresentation hook
This section details the introduction of the `IconPresentationProvider` and `useIconPresentation` hook, offering an optional way to manage icon size and container styling within the UI.
### Key Changes:
* **New Context and Hook:** Introduction of `IconPresentationProvider` and `useIconPresentation` to provide a standardized way to control the size and potentially the container component/props for icons and related interactive elements (like `ToolButton`).
### Migration Steps:
Using the `IconPresentationProvider` is **entirely optional**. If you do not wrap your components with this provider, components like `ToolButton` will continue to use their explicit `size` prop and default styling.
However, if you wish to centrally manage the presentation of icons within a specific part of your application's component tree, follow these steps:
1. **Identify the component subtree:** Determine which section of your UI you want to apply consistent icon styling to.
2. **Wrap with `IconPresentationProvider`:** Wrap the root of that component subtree with the `IconPresentationProvider`. Pass the desired `size` prop. You can also optionally provide a custom `IconContainer` component and `containerProps` if you want to change the wrapper around the icon itself (e.g., switching from a `Button` to a `ToolButton` or applying specific styling).
```jsx
import { IconPresentationProvider, ToolButton } from '@ohif/ui-next';
function MyComponentTree() {
return (
// Icons and ToolButtons within this provider will inherit 'large' size
<IconPresentationProvider size="large">
{/* Any components inside that consume the context */}
<ToolButton id="myTool" icon="Circle" tooltip="Draw Circle" />
{/* ... other components ... */}
</IconPresentationProvider>
);
}
```
3. **Consume the context in components (if building custom components):** If you are building a custom component that renders an icon and want it to respect the provider's settings, use the `useIconPresentation` hook within that component. This hook provides the configured size, a calculated CSS class name (`className`), the specified `IconContainer` component, and its `containerProps`.
```jsx
import React from 'react';
import { useIconPresentation, Icons } from '@ohif/ui-next';
function MyCustomIconButton({ iconName, ...rest }) {
// This hook reads the nearest IconPresentationProvider context
const { className, IconContainer, containerProps } = useIconPresentation();
// Use the provided IconContainer and its props
return (
<IconContainer {...rest} {...containerProps}>
{/* Use the calculated className for the icon */}
<Icons.ByName name={iconName} className={className} />
</IconContainer>
);
}
```
*Note: Built-in components like `ToolButton` in `@ohif/ui-next` have been updated internally to consume this context automatically if a provider is available.*
@@ -1,102 +1,124 @@
---
sidebar_position: 2
sidebar_label: Viewport Action Menu
summary: Migration guide for OHIF 3.11's viewport action menu customization changes, including the transition from individual item configurations to location-based arrays and the removal of index priorities.
sidebar_position: 1
sidebar_label: Viewport Corners
summary: Migration guide for OHIF 3.11's viewport corners customization changes, including the transition from individual item configurations to location-based arrays and the removal of index priorities.
---
# Viewport Action Menu Customization
In OHIF 3.11, we've redesigned how viewport action menu customizations are defined to make them more intuitive and organized by location.
Okay, here's a migration guide based on the provided diff, focusing on the introduction of `TrackingStatus`, `ModalityLoadBadge`, and `NavigationComponent`.
## Changes
**Key Changes:**
Previously, viewport action menu customizations were defined with individual item configurations that specified location and priority:
* **Deprecated `ViewportActionCornersService`**: The `ViewportActionCornersService` and its associated provider (`ViewportActionCornersProvider`) have been removed. UI elements previously managed by this service are now typically handled by dedicated components integrated via the `ToolbarService`.
* **New Centralized UI Components**:
* `ModalityLoadBadge`: A new component in `@ohif/extension-cornerstone` that displays the status (e.g., SEG/RT/SR loaded or requiring hydration) and a "LOAD" button for secondary display sets (SEG, RTSTRUCT, SR). This replaces the inline status and load logic within individual viewport components like `OHIFCornerstoneSEGViewport` and `OHIFCornerstoneRTViewport`.
* `TrackingStatus`: A new component in `@ohif/extension-cornerstone` to indicate if measurements in a viewport are being tracked. This replaces inline tracking status indicators previously in `OHIFCornerstoneSRMeasurementViewport` and `TrackedCornerstoneViewport`.
* `NavigationComponent`: A new component in `@ohif/extension-cornerstone` that provides navigation arrows (e.g., for segments in SEG/RT or measurements in SR/tracked series). This replaces the `ViewportActionArrows` previously instantiated directly within viewport components.
* **Viewport Simplification**: Viewport components like `OHIFCornerstoneSEGViewport`, `OHIFCornerstoneRTViewport`, and `OHIFCornerstoneSRMeasurementViewport` have been simplified. They no longer manage their own status indicators, load buttons, or navigation arrows. They now primarily delegate rendering to `OHIFCornerstoneViewport`.
* **Refactored Hydration Prompts**: Utility functions like `promptHydrateSEG` and `promptHydrateRT` now use a centralized `utils.promptHydrationDialog` from `@ohif/extension-cornerstone`.
* **Centralized Hydration Command**: A new command `hydrateSecondaryDisplaySet` has been added to `@ohif/extension-cornerstone` to handle the hydration logic for SEG, RTSTRUCT, and SR display sets.
* **New Hooks**: Several new hooks have been introduced in `@ohif/extension-cornerstone` (e.g., `useViewportDisplaySets`, `useMeasurementTracking`, `useViewportSegmentations`, `useViewportHover`) to provide data and state for these new UI components.
```ts
export default {
'viewportActionMenu.orientationMenu': {
enabled: true,
location: viewportActionCornersService.LOCATIONS.topLeft,
indexPriority: 1,
},
'viewportActionMenu.dataOverlay': {
enabled: true,
location: viewportActionCornersService.LOCATIONS.topLeft,
indexPriority: 2,
},
// ...
};
```
**Migration Steps:**
Now, viewport action menu customizations are organized by location, with each location having its own customization ID:
1. **Remove `ViewportActionCornersService` Usage**:
* If you were using `ViewportActionCornersService` to add custom components to viewport corners, you will need to refactor this. The recommended approach is to define these components as toolbar buttons and place them in designated viewport action menu sections (e.g., `viewportActionMenu.topLeft`) using the `ToolbarService`.
* The internal status components (`_getStatusComponent`) and `ViewportActionArrows` within specific viewports (SEG, RT, SR) have been removed. Their functionality is now provided by `ModalityLoadBadge`, `TrackingStatus`, and `NavigationComponent`.
```ts
export default {
'viewportActionMenu.topLeft': [
{
id: 'orientationMenu',
enabled: true,
},
{
id: 'dataOverlay',
enabled: true,
},
{
id: 'windowLevelActionMenu',
enabled: true,
},
],
'viewportActionMenu.topRight': [],
'viewportActionMenu.bottomLeft': [],
'viewportActionMenu.bottomRight': [],
};
```
## Migration Steps
3. **Integrate New UI Components via `ToolbarService`**:
* The `ModalityLoadBadge`, `TrackingStatus`, and `NavigationComponent` are now registered with the `ToolbarService` within the `@ohif/extension-cornerstone`'s `getToolbarModule`.
* Modes (e.g., `longitudinal`) should define toolbar sections for viewport corners and add these components to those sections.
1. Reorganize your viewport action menu customizations by using location-based customization IDs (`viewportActionMenu.topLeft`, `viewportActionMenu.topRight`, etc.).
2. For each component, move it into the appropriate location array.
3. Replace the component key with an `id` property that doesn't include the prefix (just use `orientationMenu` instead of `viewportActionMenu.orientationMenu`).
4. Remove the `location` property (since it's now implied by the customization ID).
5. Remove the `indexPriority` property (order in the array now determines display order).
6. For each component, provide a `component` function that returns the component instance.
*Example: Adding components to viewport corners in `longitudinal` mode*
```diff
// modes/longitudinal/src/index.ts
function modeFactory({ modeConfiguration }) {
return {
// ...
onModeEnter: ({ servicesManager, extensionManager, commandsManager }: withAppTypes) => {
// ...
toolbarService.addButtons(toolbarButtons);
toolbarService.createButtonSection('primary', [
// ... primary tools
]);
## Component Rendering
+ toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topLeft, [
+ 'orientationMenu',
+ 'dataOverlayMenu',
+ 'windowLevelMenu',
+ ]);
+ toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topRight, [
+ 'modalityLoadBadge',
+ 'trackingStatus',
+ 'navigationComponent',
+ ]);
// ...
},
```
And ensure these buttons are defined in your mode's `toolbarButtons.ts`:
```diff
// modes/longitudinal/src/toolbarButtons.ts
+ {
+ id: 'modalityLoadBadge',
+ uiType: 'ohif.modalityLoadBadge',
+ props: {
+ // ... props like icon, label, tooltip, evaluate
+ evaluate: {
+ name: 'evaluate.modalityLoadBadge',
+ hideWhenDisabled: true,
+ },
+ },
+ },
+ {
+ id: 'navigationComponent',
+ uiType: 'ohif.navigationComponent',
+ props: {
+ // ... props
+ evaluate: {
+ name: 'evaluate.navigationComponent',
+ hideWhenDisabled: true,
+ },
+ },
+ },
+ {
+ id: 'trackingStatus',
+ uiType: 'ohif.trackingStatus',
+ props: {
+ // ... props
+ evaluate: {
+ name: 'evaluate.trackingStatus',
+ hideWhenDisabled: true,
+ },
+ },
+ },
```
Component rendering logic is now included directly in the item configuration via a `component` function:
```ts
const createOrientationMenu = ({ viewportId, element, location }) => {
return getViewportOrientationMenu({
viewportId,
element,
location,
});
};
5. **Direct Import of `OHIFCornerstoneViewport`**:
* Extensions that were previously getting the cornerstone viewport component dynamically via `extensionManager.getModuleEntry('@ohif/extension-cornerstone.viewportModule.cornerstone')` should now import `OHIFCornerstoneViewport` directly from `@ohif/extension-cornerstone`.
const createDataOverlay = ({ viewportId, element, displaySets, location }) => {
return getViewportDataOverlaySettingsMenu({
viewportId,
element,
displaySets,
location,
});
};
```diff
// extensions/cornerstone-dicom-pmap/src/viewports/OHIFCornerstonePMAPViewport.tsx
import PropTypes from 'prop-types';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useViewportGrid } from '@ohif/ui-next';
+import { OHIFCornerstoneViewport } from '@ohif/extension-cornerstone';
export default {
'viewportActionMenu.topLeft': [
{
id: 'orientationMenu',
enabled: true,
component: createOrientationMenu,
},
{
id: 'dataOverlay',
enabled: true,
component: createDataOverlay,
},
// other components...
],
// other locations...
};
```
function OHIFCornerstonePMAPViewport(props: withAppTypes) {
// ...
const getCornerstoneViewport = useCallback(() => {
- const { component: Component } = extensionManager.getModuleEntry(
- '@ohif/extension-cornerstone.viewportModule.cornerstone'
- );
// ...
return (
- <Component
+ <OHIFCornerstoneViewport
{...props}
// ...
- ></Component>
+ />
);
// ...
```
@@ -0,0 +1,33 @@
---
title: Hooks
summary: List of React hooks available in the platform, these are custom hooks that are used to access the state of the platform
---
# Hooks
## [useMeasurements](./useMeasurements.md)
A React hook that provides mapped measurements from the measurement service with automatic updates when measurements change.
## [useViewportSegmentations](./useViewportSegmentations.md)
A React hook that provides segmentation data and representations for the active viewport with automatic updates when segmentations change.
## [useMeasurementTracking](./useMeasurementTracking.md)
A React hook that provides measurement tracking information for a specific viewport, including tracking state and tracked measurement UIDs.
## [useViewportDisplaySets](./useViewportDisplaySets.md)
A React hook that provides access to display sets associated with a viewport, including background, foreground, overlay, and potential display sets.
## [useViewportHover](./useViewportHover.md)
A React hook that tracks mouse hover state and active status for a specific viewport.
## [usePatientInfo](./usePatientInfo.md)
A React hook that provides patient information from the active display sets and detects when multiple patients are loaded.
## [useSearchParams](./useSearchParams.md)
A React hook that provides access to URL search parameters from both the query string and hash fragment.
## [useDynamicMaxHeight](./useDynamicMaxHeight.md)
A React hook that calculates the maximum height for an element based on its position in the viewport, with automatic recalculation on window resize or data changes.
## [useSessionStorage](./useSessionStorage.md)
A React hook that provides sessionStorage access with automatic JSON parsing/stringifying and an option to clear data when the page unloads.
@@ -0,0 +1,65 @@
---
title: useDynamicMaxHeight
summary: A React hook that calculates the maximum height for an element based on its position in the viewport, with automatic recalculation on window resize or data changes.
---
# useDynamicMaxHeight
The `useDynamicMaxHeight` hook calculates the maximum height an element can have based on its position relative to the bottom of the viewport, ensuring it doesn't overflow or get cut off by the viewport edge.
## Overview
This hook is useful for creating responsive UI elements that need to fit within the visible area of the screen without causing scrolling or content overflow. It automatically recalculates the maximum height when the window is resized or when specified data changes, ensuring the element always fits properly.
## Import
```js
import { useDynamicMaxHeight } from '@ohif/ui-next';
```
## Usage
```jsx
function DynamicHeightPanel({ data, children }) {
const { ref, maxHeight } = useDynamicMaxHeight(data, 30, 200);
return (
<div
ref={ref}
style={{
maxHeight,
overflow: 'auto',
border: '1px solid gray'
}}
>
{children}
</div>
);
}
```
## Parameters
- `data` (required): Any data that, when changed, should trigger a recalculation of the maximum height. This can be an array, object, or primitive value.
- `buffer` (optional): Additional space (in pixels) to leave below the element. Defaults to 20px.
- `minHeight` (optional): Minimum height (in pixels) for the element. Defaults to 100px.
## Returns
An object containing:
- `ref`: A React ref object that must be attached to the target DOM element
- `maxHeight`: A CSS-compatible string value for the calculated maximum height (e.g., "500px")
## Implementation Details
- The hook uses `window.innerHeight` and the element's position to calculate the available space between the element's top edge and the bottom of the viewport.
- It subtracts the specified buffer from the available height to ensure there's space below the element.
- The calculated height is constrained by the specified minimum height to prevent the element from becoming too small.
- The hook uses `requestAnimationFrame` to ensure the initial calculation happens after the component has been rendered and positioned in the DOM.
- It automatically recalculates the maximum height when:
- The window is resized
- The `data`, `buffer`, or `minHeight` dependencies change
- The hook properly cleans up event listeners and animation frame requests when the component unmounts.
This hook is particularly useful for panels, menus, and content areas that need to dynamically adjust their height based on their position in the viewport, ensuring a good user experience without unexpected scrolling or content clipping.
@@ -0,0 +1,79 @@
---
title: useMeasurementTracking
summary: A React hook that provides measurement tracking information for a specific viewport, including tracking state and tracked measurement UIDs.
---
# useMeasurementTracking
The `useMeasurementTracking` hook provides measurement tracking information for a specific viewport, including the tracking state and UIDs of tracked measurements associated with the viewport's series.
## Overview
This hook gives components access to tracking information for measurements in a viewport. It monitors the tracked measurements service and measurement service to provide up-to-date tracking states and the list of measurement UIDs associated with the series displayed in the viewport.
## Import
```js
import { useMeasurementTracking } from '@ohif/extension-cornerstone';
```
## Usage
```jsx
function MeasurementTrackingInfo({ viewportId }) {
const {
isTracked,
isLocked,
seriesInstanceUID,
trackedMeasurementUIDs
} = useMeasurementTracking({
viewportId,
});
return (
<div>
<div>Series UID: {seriesInstanceUID}</div>
<div>Tracking Status: {isTracked ? 'Tracked' : 'Not Tracked'}</div>
<div>Locked: {isLocked ? 'Yes' : 'No'}</div>
<div>Tracked Measurements: {trackedMeasurementUIDs.length}</div>
<ul>
{trackedMeasurementUIDs.map(uid => (
<li key={uid}>{uid}</li>
))}
</ul>
</div>
);
}
```
## Parameters
- `options` - Configuration options:
- `viewportId` (required): The ID of the viewport to track
## Returns
An object containing the following properties:
- `isTracked`: Boolean indicating if the series in the viewport is currently tracked
- `isLocked`: Boolean indicating if tracking is enabled (locked) globally
- `seriesInstanceUID`: The Series Instance UID of the background display set in the viewport
- `trackedMeasurementUIDs`: Array of measurement UIDs that are associated with the tracked series in the viewport
## Events
The hook automatically updates when any of these events occur:
From the tracked measurements service:
- `TRACKING_ENABLED`
- `TRACKING_DISABLED`
- `TRACKED_SERIES_CHANGED`
- `SERIES_ADDED`
- `SERIES_REMOVED`
From the measurement service:
- `MEASUREMENT_ADDED`
- `RAW_MEASUREMENT_ADDED`
- `MEASUREMENT_UPDATED`
- `MEASUREMENT_REMOVED`
- `MEASUREMENTS_CLEARED`
@@ -0,0 +1,68 @@
---
title: useMeasurements
summary: A React hook that provides mapped measurements from the measurement service with automatic updates when measurements change.
---
# useMeasurements
The `useMeasurements` hook provides access to measurements from the measurement service, with automatic updates when measurements are added, updated, or removed.
## Overview
This hook retrieves measurements from the measurement service and maps them to a display-friendly format. It monitors various measurement service events and updates the returned measurements automatically when changes occur.
## Import
```js
import { useMeasurements } from '@ohif/extension-cornerstone';
```
## Usage
```jsx
function MeasurementPanel() {
const measurementFilter = measurements => measurements.someFilter;
const measurements = useMeasurements({
measurementFilter,
});
return (
<div>
{measurements.map(measurement => (
<div key={measurement.uid}>
<span>{measurement.label}</span>
<div>
{measurement.displayText.primary.map((text, i) => (
<p key={i}>{text}</p>
))}
</div>
</div>
))}
</div>
);
}
```
## Parameters
- `options` - Configuration options:
- `measurementFilter` - Optional function to filter measurements returned by the measurement service.
## Returns
An array of mapped measurements with the following structure:
## Events
The hook automatically updates when any of these measurement service events occur:
- `MEASUREMENT_ADDED`
- `RAW_MEASUREMENT_ADDED`
- `MEASUREMENT_UPDATED`
- `MEASUREMENT_REMOVED`
- `MEASUREMENTS_CLEARED`
## Implementation Details
The hook uses debouncing to prevent excessive re-renders when multiple measurement events occur in rapid succession. It also performs a deep comparison of the measurements to avoid unnecessary state updates when the data hasn't actually changed.
@@ -0,0 +1,68 @@
---
title: usePatientInfo
summary: A React hook that provides patient information from the active display sets and detects when multiple patients are loaded.
---
# usePatientInfo
The `usePatientInfo` hook provides access to basic patient demographic information from the active display sets and also detects when display sets from multiple patients are loaded simultaneously.
## Overview
This hook retrieves patient information from the first instance of the first display set added to the viewer. It monitors when new display sets are added and updates the patient information accordingly. It also checks if any of the active display sets are from different patients and provides this information through the `isMixedPatients` flag.
## Import
```js
import { usePatientInfo } from '@ohif/extension-default';
```
## Usage
```jsx
function PatientBanner() {
const { patientInfo, isMixedPatients } = usePatientInfo();
return (
<div className="patient-banner">
{isMixedPatients && (
<div className="warning">Multiple patients loaded</div>
)}
<div className="patient-name">{patientInfo.PatientName}</div>
<div className="patient-details">
<span>ID: {patientInfo.PatientID}</span>
<span>Sex: {patientInfo.PatientSex}</span>
<span>DOB: {patientInfo.PatientDOB}</span>
</div>
</div>
);
}
```
## Parameters
This hook doesn't take any parameters.
## Returns
An object containing:
- `patientInfo`: Object with the following properties:
- `PatientName`: Formatted patient name
- `PatientID`: Patient identifier
- `PatientSex`: Patient sex
- `PatientDOB`: Formatted patient date of birth
- `isMixedPatients`: Boolean indicating whether multiple patients are loaded in the viewer
## Events
The hook subscribes to the following display set service events:
- `DISPLAY_SETS_ADDED`: Updates patient information when new display sets are added to the viewer
## Implementation Details
- Patient name and date of birth are formatted using OHIF utility functions.
- The hook checks all active display sets to determine if they belong to different patients.
- Patient information is initialized with empty strings and updated when display sets are added.
- When no instances are available in a display set, the hook attempts to get information from the `instance` property as a fallback.
@@ -0,0 +1,69 @@
---
title: useSearchParams
summary: A React hook that provides access to URL search parameters from both the query string and hash fragment.
---
# useSearchParams
The `useSearchParams` hook provides access to URL search parameters, combining both query string parameters and hash fragment parameters into a single URLSearchParams object.
## Overview
This hook extends the standard React Router `useLocation` functionality by merging search parameters from both the query string and the hash fragment of the URL. It also provides an option to normalize parameter keys to lowercase for case-insensitive parameter handling.
## Import
```js
import { useSearchParams } from '@ohif/app';
```
## Usage
```jsx
function RouteParameterReader() {
const searchParams = useSearchParams();
// Or with lowercase keys option
const lowerCaseParams = useSearchParams({ lowerCaseKeys: true });
const studyInstanceUID = searchParams.get('StudyInstanceUID');
// With lowerCaseKeys: true
const sameStudyUID = lowerCaseParams.get('studyinstanceuid');
return (
<div>
<div>Study UID: {studyInstanceUID}</div>
<div>All Parameters:</div>
<ul>
{Array.from(searchParams.entries()).map(([key, value]) => (
<li key={key}>
{key}: {value}
</li>
))}
</ul>
</div>
);
}
```
## Parameters
- `options` (optional): Configuration options
- `lowerCaseKeys` - Boolean indicating whether to convert all parameter keys to lowercase (default: false)
## Returns
A `URLSearchParams` object containing all parameters from both:
- The query string (e.g., `?param1=value1&param2=value2`)
- The hash fragment (e.g., `#param3=value3&param4=value4`)
If parameters with the same key exist in both the query string and hash fragment, the hash fragment values take precedence.
## Implementation Details
- The hook uses React Router's `useLocation` to access the current URL.
- It first creates a URLSearchParams object from the location's search property.
- It then creates another URLSearchParams object from the location's hash property (excluding the leading '#').
- The parameters from the hash are added to the search parameters, overriding any duplicate keys.
- If the `lowerCaseKeys` option is enabled, it creates a new URLSearchParams object with all keys converted to lowercase.
This is particularly useful in OHIF where parameters may be specified in either the query string or hash fragment, and where case-sensitivity of parameter names might vary across different systems.
@@ -0,0 +1,84 @@
---
title: useSessionStorage
summary: A React hook that provides sessionStorage access with automatic JSON parsing/stringifying and an option to clear data when the page unloads.
---
# useSessionStorage
The `useSessionStorage` hook provides a convenient way to store and retrieve data from the browser's sessionStorage with automatic JSON serialization and deserialization. It also offers the option to automatically clear the stored data when a page unloads.
## Overview
This hook wraps the browser's sessionStorage API to provide a more React-friendly interface. It handles JSON serialization/deserialization automatically and maintains the stored values in local state for reactive updates. The hook also includes a unique feature to clear specific items from sessionStorage when the page unloads, which is useful for temporary session data that shouldn't persist.
## Import
```js
import { useSessionStorage } from '@ohif/ui-next';
```
## Usage
```jsx
function UserPreferencesPanel() {
const [preferences, setPreferences] = useSessionStorage({
key: 'viewer-preferences',
defaultValue: { theme: 'dark', fontSize: 'medium' },
clearOnUnload: false,
});
const updateTheme = (theme) => {
setPreferences({ ...preferences, theme });
};
return (
<div>
<h3>User Preferences</h3>
<div>Current Theme: {preferences.theme}</div>
<button onClick={() => updateTheme('light')}>Light Theme</button>
<button onClick={() => updateTheme('dark')}>Dark Theme</button>
</div>
);
}
function TemporaryWorkspace() {
const [workspace, setWorkspace] = useSessionStorage({
key: 'temp-workspace',
defaultValue: { annotations: [] },
clearOnUnload: true, // This data will be cleared when the page unloads
});
return (
<div>
<h3>Temporary Workspace</h3>
<p>This workspace will be cleared when you leave the page</p>
{/* Workspace UI components */}
</div>
);
}
```
## Parameters
An options object with the following properties:
- `key` (required): The sessionStorage key under which to store the data
- `defaultValue` (optional): The default value to use if no data exists in sessionStorage for the given key. Defaults to an empty object (`{}`)
- `clearOnUnload` (optional): Whether to clear this item from sessionStorage when the page unloads. Defaults to `false`
## Returns
An array containing:
1. The current value from sessionStorage (parsed from JSON)
2. A function to update the value in both state and sessionStorage
The update function automatically handles JSON stringification of the data.
## Implementation Details
- The hook uses a global Map (`sessionItemsToClearOnUnload`) to track items that should be cleared when the page unloads.
- It utilizes the browser's `visibilitychange` event to implement the clearOnUnload feature. When the page becomes hidden (which happens both when switching tabs and when unloading), items marked for clearing are removed from sessionStorage.
- If the page later becomes visible again (e.g., when switching back to the tab), the items are restored from the Map.
- The hook's update function merges the new value with the current state using the spread operator, maintaining a similar behavior to React's `setState`.
- All data is automatically serialized to JSON before storing in sessionStorage and deserialized when retrieving.
@@ -0,0 +1,98 @@
---
title: useViewportDisplaySets
summary: A React hook that provides access to display sets associated with a viewport, including background, foreground, overlay, and potential display sets.
---
# useViewportDisplaySets
The `useViewportDisplaySets` hook provides access to display sets associated with a viewport, organized into categories based on their role and potential usage.
## Overview
This hook retrieves all display sets associated with a specific viewport and categorizes them into background, foreground, overlays, and potential display sets that could be added to the viewport in different roles. It allows components to efficiently access and manage viewport display sets for various UI interactions like layer menus and display set selectors.
## Import
```js
import { useViewportDisplaySets } from '@ohif/extension-cornerstone';
```
## Usage
```jsx
function ViewportLayerControls({ viewportId }) {
const {
backgroundDisplaySet,
foregroundDisplaySets,
overlayDisplaySets,
potentialOverlayDisplaySets,
potentialForegroundDisplaySets,
potentialBackgroundDisplaySets,
} = useViewportDisplaySets(viewportId);
return (
<div>
<div>
<h3>Background</h3>
<div>{backgroundDisplaySet?.SeriesDescription}</div>
</div>
<div>
<h3>Foreground ({foregroundDisplaySets.length})</h3>
{foregroundDisplaySets.map(ds => (
<div key={ds.displaySetInstanceUID}>{ds.SeriesDescription}</div>
))}
</div>
<div>
<h3>Overlays ({overlayDisplaySets.length})</h3>
{overlayDisplaySets.map(ds => (
<div key={ds.displaySetInstanceUID}>{ds.SeriesDescription}</div>
))}
</div>
<div>
<h3>Available Overlays ({potentialOverlayDisplaySets.length})</h3>
{potentialOverlayDisplaySets.map(ds => (
<div key={ds.displaySetInstanceUID}>{ds.SeriesDescription}</div>
))}
</div>
</div>
);
}
```
## Parameters
- `viewportId` (optional): The ID of the viewport to get display sets for. If not provided, uses the active viewport.
- `options` (optional): Configuration options to control which display sets to include:
- `includeBackground`: Whether to include the background display set (default: true)
- `includeForeground`: Whether to include foreground display sets (default: true)
- `includeOverlay`: Whether to include overlay display sets (default: true)
- `includePotentialOverlay`: Whether to include potential overlay display sets (default: true)
- `includePotentialForeground`: Whether to include potential foreground display sets (default: true)
- `includePotentialBackground`: Whether to include potential background display sets (default: true)
## Returns
An object containing requested display set collections based on options:
- `allDisplaySets`: All display sets in the viewer (only if requested)
- `viewportDisplaySets`: The display sets currently in the viewport
- `backgroundDisplaySet`: The primary display set for the viewport (base image)
- `foregroundDisplaySets`: Display sets currently shown with background (non-overlay layers)
- `overlayDisplaySets`: Segmentation display sets currently applied as overlays
- `potentialOverlayDisplaySets`: Display sets that could be toggled on as overlays (derived modalities)
- `potentialForegroundDisplaySets`: Display sets that could be added as foreground layers
- `potentialBackgroundDisplaySets`: Display sets that could replace the current background
Each property is only included if the corresponding option is true.
## Implementation Details
- The hook automatically adapts to viewport changes through the `useViewportGrid` hook.
- It only fetches and processes display sets that are needed based on the provided options.
- Display sets are categorized based on their modality and properties.
- Potential display sets are sorted by priority to present the most relevant options first.
- Derived overlay modalities (like SEG, SR) are treated differently than other display sets.
- The hook uses memoization extensively to optimize performance and prevent unnecessary recalculations.
@@ -0,0 +1,57 @@
---
title: useViewportHover
summary: A React hook that tracks mouse hover state and active status for a specific viewport.
---
# useViewportHover
The `useViewportHover` hook provides information about whether the mouse is currently hovering over a specific viewport and whether that viewport is active.
## Overview
This hook monitors mouse movement to track hover state over a viewport element by its ID. It also checks whether the viewport is currently active (selected) in the viewer. This is useful for implementing conditional UI elements or behaviors that depend on user interaction with viewports.
## Import
```js
import { useViewportHover } from '@ohif/extension-cornerstone';
```
## Usage
```jsx
function ViewportOverlay({ viewportId }) {
const { isHovered, isActive } = useViewportHover(viewportId);
return (
<div className={`overlay ${isHovered ? 'hovered' : ''} ${isActive ? 'active' : ''}`}>
{isHovered && !isActive && (
<button>Click to activate</button>
)}
{isActive && (
<div>Active viewport controls</div>
)}
</div>
);
}
```
## Parameters
- `viewportId` (required): The ID of the viewport to track hover state for
## Returns
An object containing the following properties:
- `isHovered`: Boolean indicating if the mouse is currently hovering over the viewport
- `isActive`: Boolean indicating if the viewport is currently the active viewport in the grid
## Implementation Details
- The hook uses the DOM to find the viewport element by its `data-viewportId` attribute.
- It calculates and maintains the viewport's bounding rectangle to efficiently determine if the mouse is within the viewport's bounds.
- The viewport element's rectangle is updated when the window is resized.
- Global mouse movement is tracked to determine hover state, rather than relying on traditional mouseenter/mouseleave events.
- The hook automatically cleans up event listeners when the component unmounts or the viewport ID changes.
- Active viewport state is derived from the viewport grid state using the `useViewportGrid` hook.
@@ -0,0 +1,95 @@
---
title: useViewportSegmentations
summary: A React hook that provides segmentation data and representations for the active viewport with automatic updates when segmentations change.
---
# useViewportSegmentations
The `useViewportSegmentations` hook provides access to segmentation data and their representations for a specific viewport, with automatic updates when segmentations are modified, removed, or representations change.
## Overview
This hook retrieves all segmentations and their representations for a given viewport from the segmentation service. It maps the segmentation data to a display-friendly format, including readable text for statistics. The hook monitors various segmentation and viewport events to update automatically when changes occur.
## Import
```js
import { useViewportSegmentations } from '@ohif/extension-cornerstone';
```
## Usage
```jsx
function SegmentationPanel({ viewportId }) {
const { segmentationsWithRepresentations, disabled } = useViewportSegmentations({
viewportId,
subscribeToDataModified: true,
debounceTime: 100,
});
if (disabled) {
return <div>Segmentations not available for this modality</div>;
}
if (!segmentationsWithRepresentations.length) {
return <div>No segmentations available</div>;
}
return (
<div>
{segmentationsWithRepresentations.map(({ segmentation, representation }) => (
<div key={segmentation.id}>
<h3>{segmentation.label}</h3>
{Object.entries(segmentation.segments).map(([segmentIndex, segment]) => (
<div key={segmentIndex}>
<h4>{segment.label}</h4>
{segment.displayText.primary.map((text, i) => (
<p key={i}>{text}</p>
))}
</div>
))}
</div>
))}
</div>
);
}
```
## Parameters
- `options` - Configuration options:
- `viewportId` (required): The ID of the viewport to get segmentations for
- `subscribeToDataModified` (optional): Whether to subscribe to segmentation data modifications (default: false)
- `debounceTime` (optional): Debounce time in milliseconds for updates (default: 0)
## Returns
An object with the following properties:
- `segmentationsWithRepresentations`: An array of objects with each containing:
- `representation`: The segmentation representation in the viewport
- `segmentation`: The mapped segmentation data with display-friendly properties:
- `label`: The segmentation label
- `segments`: Object mapping segment indices to segment data:
- `label`: Segment label
- `color`: Segment color
- `displayText`: Organized text for display with `primary` and `secondary` arrays
- `disabled`: Boolean indicating if segmentations are disabled for the current modality
## Events
The hook automatically updates when any of these events occur:
- `SEGMENTATION_MODIFIED`
- `SEGMENTATION_REMOVED`
- `SEGMENTATION_REPRESENTATION_MODIFIED`
- `ACTIVE_VIEWPORT_ID_CHANGED`
- `GRID_STATE_CHANGED`
- `SEGMENTATION_DATA_MODIFIED` (only if `subscribeToDataModified` is true)
## Implementation Details
- The hook excludes certain modalities from segmentation display: 'SM', 'OT', 'DOC', 'ECG'.
- It uses debouncing to prevent excessive re-renders when multiple segmentation events occur in rapid succession.
- Segmentation statistics are automatically mapped to readable text using the customization service.
- Nested statistics are displayed with indentation to maintain hierarchy.
@@ -23,7 +23,6 @@ import segDisplayEditingTrue from '../../../assets/img/segDisplayEditingTrue.png
import segDisplayEditingFalse from '../../../assets/img/segDisplayEditingFalse.png';
import thumbnailMenuItemsImage from '../../../assets/img/thumbnailMenuItemsImage.png';
import studyMenuItemsImage from '../../../assets/img/studyMenuItemsImage.png';
import windowLevelActionMenu from '../../../assets/img/windowLevelActionMenu.png';
import viewPortNotificationImage from '../../../assets/img/viewport-notification.png';
import captureViewportModal from '../../../assets/img/captureViewportModal.png';
import aboutModal from '../../../assets/img/aboutModal.png';
@@ -201,31 +200,6 @@ window.config = {
],
`,
},
{
id: 'viewportActionMenu.windowLevelActionMenu',
description:
'Configures the display and location of the window level action menu in the viewport.',
image: windowLevelActionMenu,
default: null,
configuration: `
window.config = {
// rest of window config
customizationService: [
{
'viewportActionMenu.windowLevelActionMenu': {
$merge: {
location: 0, // Set the location of the menu in the viewport.
// 0: topLeft
// 1: topRight
// 2: bottomLeft
// 3: bottomRight
}
},
},
],
};
`,
},
{
id: 'measurementLabels',
description: 'Labels for measurement tools in the viewer that are automatically asked for.',
@@ -936,56 +910,6 @@ window.config = {
};
`,
},
{
id: 'viewportActionMenu.windowLevelActionMenu',
description:
'Configures the display and location of the window level action menu in the viewport.',
image: windowLevelActionMenu,
default: null,
configuration: `
window.config = {
// rest of window config
customizationService: [
{
'viewportActionMenu.windowLevelActionMenu': {
$merge: {
location: 0, // Set the location of the menu in the viewport.
// 0: topLeft
// 1: topRight
// 2: bottomLeft
// 3: bottomRight
}
},
},
],
};
`,
},
{
id: 'viewportActionMenu.dataOverlayMenu',
description: 'Configures the display and location of the data overlay in the viewport.',
image: segmentationOverlay,
default: null,
configuration: `
window.config = {
// rest of window config
customizationService: [
{
'viewportActionMenu.dataOverlayMenu': {
$merge: {
enabled: true,
location: 1, // Set the location of the overlay in the viewport.
// 0: topLeft
// 1: topRight
// 2: bottomLeft
// 3: bottomRight
}
},
},
],
};
`,
},
{
id: 'viewportNotification.beginTrackingMessage',
description: 'Define the content to be displayed in begin tracking prompt',
@@ -50,7 +50,9 @@ export interface MenuProps {
showHeaderDivider?: boolean;
activePanelIndex?: number;
onVisibilityChange?: (isVisible: boolean) => void;
horizontalDirection?: HorizontalDirection;
// New props that can be used as alternatives to horizontalDirection and verticalDirection
align?: 'start' | 'end' | 'center';
side?: 'top' | 'bottom' | 'left' | 'right';
children: ReactNode;
}
type MenuContextProps = {
@@ -58,6 +60,7 @@ type MenuContextProps = {
hideMenu: () => void;
addItemPanel: (index: number, label: string) => void;
horizontalDirection: HorizontalDirection;
verticalDirection?: VerticalDirection;
activePanelIndex: number;
};
@@ -76,9 +79,24 @@ const Menu = (props: MenuProps) => {
preventHideMenu,
menuClassName,
menuStyle,
horizontalDirection = HorizontalDirection.LeftToRight,
align,
side,
} = props;
// Derive horizontalDirection and verticalDirection from align and side if provided
let horizontalDirection = HorizontalDirection.LeftToRight;
let verticalDirection = VerticalDirection.BottomToTop;
if (align !== undefined) {
horizontalDirection =
align === 'start' ? HorizontalDirection.LeftToRight : HorizontalDirection.RightToLeft;
}
if (side !== undefined) {
verticalDirection =
side === 'bottom' ? VerticalDirection.TopToBottom : VerticalDirection.BottomToTop;
}
const [isMenuVisible, setIsMenuVisible] = useState(isVisible);
// The menuPath is an array consisting of this top Menu and every SubMenu
@@ -151,6 +169,7 @@ const Menu = (props: MenuProps) => {
addItemPanel,
activePanelIndex: currentMenuActivePanelIndex,
horizontalDirection,
verticalDirection,
}}
>
{isMenuVisible && (
@@ -70,20 +70,7 @@ const CinePlayer: React.FC<CinePlayerProps> = ({
);
return (
<div className={className}>
{isDynamic && dynamicInfo && (
<Numeric.Container
mode="singleRange"
min={1}
max={dynamicInfo.numDimensionGroups}
step={1}
value={dynamicInfo.dimensionGroupNumber}
onChange={val => handleDimensionGroupNumberChange(val as number)}
className="mb-3 w-full"
>
<Numeric.SingleRange showNumberInput={false} />
</Numeric.Container>
)}
<div className={`absolute top-10 left-1/2 z-50 -translate-x-1/2 ${className}`}>
<div className={'bg-muted inline-flex select-none items-center gap-2 rounded-md px-2 py-2'}>
<Button
variant="ghost"
@@ -139,7 +126,7 @@ const CinePlayer: React.FC<CinePlayerProps> = ({
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
side="bottom"
align="center"
className="cine-fps-range-popover z-50 w-auto p-2"
sideOffset={8}
@@ -171,6 +158,20 @@ const CinePlayer: React.FC<CinePlayerProps> = ({
<Icons.Close />
</Button>
</div>
{isDynamic && dynamicInfo && (
<Numeric.Container
mode="singleRange"
min={1}
max={dynamicInfo.numDimensionGroups}
step={1}
value={dynamicInfo.dimensionGroupNumber}
onChange={val => handleDimensionGroupNumberChange(val as number)}
className="mt-3 w-full"
>
<Numeric.SingleRange showNumberInput={false} />
</Numeric.Container>
)}
</div>
);
};
@@ -12,7 +12,6 @@ interface DoubleSliderProps {
defaultValue?: [number, number];
onValueChange?: (value: [number, number]) => void;
showNumberInputs?: boolean;
lockMode?: boolean;
}
const DoubleSlider = React.forwardRef<HTMLDivElement, DoubleSliderProps>(
@@ -25,19 +24,10 @@ const DoubleSlider = React.forwardRef<HTMLDivElement, DoubleSliderProps>(
step = 1,
defaultValue = [min, max],
showNumberInputs = false,
lockMode = false,
},
ref
) => {
const [value, setValue] = React.useState<[number, number]>(defaultValue);
const trackRef = React.useRef<HTMLDivElement>(null);
const dragStateRef = React.useRef<{
startX: number;
startValue: [number, number];
rangeWidth: number;
trackLeft: number;
trackWidth: number;
} | null>(null);
const prevDefaultValueRef = React.useRef<[number, number] | null>(null);
@@ -60,172 +50,8 @@ const DoubleSlider = React.forwardRef<HTMLDivElement, DoubleSliderProps>(
return Math.round(num * inverse) / inverse;
};
const handleRangePointerDown = (event: React.PointerEvent) => {
if (!lockMode) {
return;
}
event.preventDefault();
const rect = trackRef.current!.getBoundingClientRect();
dragStateRef.current = {
startX: event.clientX,
startValue: [...value] as [number, number],
rangeWidth: value[1] - value[0],
trackLeft: rect.left,
trackWidth: rect.width,
};
window.addEventListener('pointermove', handleRangePointerMove);
window.addEventListener('pointerup', handleRangePointerUp, { once: true });
};
const handleRangePointerMove = (event: PointerEvent) => {
const state = dragStateRef.current;
if (!state) {
return;
}
const { startX, startValue, rangeWidth, trackWidth } = state;
const dxPx = event.clientX - startX;
const pct = dxPx / trackWidth;
const delta = pct * (max - min);
let newLeft = startValue[0] + delta;
let newRight = newLeft + rangeWidth;
// clamp to bounds
if (newLeft < min) {
newLeft = min;
newRight = min + rangeWidth;
} else if (newRight > max) {
newRight = max;
newLeft = max - rangeWidth;
}
const clampedLeft = roundToStep(newLeft);
const clampedRight = roundToStep(newRight);
setValue([clampedLeft, clampedRight]);
onValueChange?.([clampedLeft, clampedRight]);
};
const handleRangePointerUp = () => {
dragStateRef.current = null;
window.removeEventListener('pointermove', handleRangePointerMove);
};
const handleSliderChange = React.useCallback(
(newValue: number[]) => {
// Default behavior (no lock mode)
if (!lockMode) {
const clampedValue: [number, number] = [
roundToStep(Math.max(min, Math.min(newValue[0], max))),
roundToStep(Math.min(max, Math.max(newValue[1], min))),
];
setValue(clampedValue);
onValueChange?.(clampedValue);
return;
}
// Lock mode behavior
// First, determine if this is a symmetric expansion/contraction or a range shift
// by checking which thumb(s) moved
const isLeftThumbMoved = newValue[0] !== value[0];
const isRightThumbMoved = newValue[1] !== value[1];
// Case 1: Left thumb moved, right thumb needs to move symmetrically (opposite)
if (isLeftThumbMoved && !isRightThumbMoved) {
const delta = newValue[0] - value[0];
const centerPoint = (value[0] + value[1]) / 2;
// Symmetric movement: move right thumb in opposite direction
const newRight = value[1] - delta;
// Ensure values stay within bounds
const clampedLeft = roundToStep(Math.max(min, Math.min(newValue[0], max)));
const clampedRight = roundToStep(Math.min(max, Math.max(newRight, min)));
// Special case: If right would go out of bounds, adjust left too to maintain center
if (newRight !== clampedRight) {
// Calculate new delta given the clamped right value
const adjustedDelta = value[1] - clampedRight;
// Apply same delta to left but in opposite direction
const adjustedLeft = value[0] + adjustedDelta;
const finalClampedLeft = roundToStep(Math.max(min, Math.min(adjustedLeft, max)));
const finalValues: [number, number] = [finalClampedLeft, clampedRight];
setValue(finalValues);
onValueChange?.(finalValues);
return;
}
const finalValues: [number, number] = [clampedLeft, clampedRight];
setValue(finalValues);
onValueChange?.(finalValues);
return;
}
// Case 2: Right thumb moved, left thumb needs to move symmetrically (opposite)
if (!isLeftThumbMoved && isRightThumbMoved) {
const delta = newValue[1] - value[1];
const centerPoint = (value[0] + value[1]) / 2;
// Symmetric movement: move left thumb in opposite direction
const newLeft = value[0] - delta;
// Ensure values stay within bounds
const clampedRight = roundToStep(Math.min(max, Math.max(newValue[1], min)));
const clampedLeft = roundToStep(Math.max(min, Math.min(newLeft, max)));
// Special case: If left would go out of bounds, adjust right too to maintain center
if (newLeft !== clampedLeft) {
// Calculate new delta given the clamped left value
const adjustedDelta = value[0] - clampedLeft;
// Apply same delta to right but in opposite direction
const adjustedRight = value[1] + adjustedDelta;
const finalClampedRight = roundToStep(Math.min(max, Math.max(adjustedRight, min)));
const finalValues: [number, number] = [clampedLeft, finalClampedRight];
setValue(finalValues);
onValueChange?.(finalValues);
return;
}
const finalValues: [number, number] = [clampedLeft, clampedRight];
setValue(finalValues);
onValueChange?.(finalValues);
return;
}
// Case 3: Both thumbs moved (range shift) - maintain the same distance between thumbs
if (isLeftThumbMoved && isRightThumbMoved) {
const rangeWidth = value[1] - value[0];
// Calculate how much the left thumb moved
const leftDelta = newValue[0] - value[0];
// New values maintaining the same range width
let newLeft = newValue[0];
let newRight = newLeft + rangeWidth;
// Handle bounds checking
if (newRight > max) {
newRight = max;
newLeft = newRight - rangeWidth;
}
if (newLeft < min) {
newLeft = min;
newRight = newLeft + rangeWidth;
}
const clampedValues: [number, number] = [
roundToStep(Math.max(min, Math.min(newLeft, max))),
roundToStep(Math.min(max, Math.max(newRight, min))),
];
setValue(clampedValues);
onValueChange?.(clampedValues);
return;
}
// Fallback to default behavior
const clampedValue: [number, number] = [
roundToStep(Math.max(min, Math.min(newValue[0], max))),
roundToStep(Math.min(max, Math.max(newValue[1], min))),
@@ -233,86 +59,25 @@ const DoubleSlider = React.forwardRef<HTMLDivElement, DoubleSliderProps>(
setValue(clampedValue);
onValueChange?.(clampedValue);
},
[min, max, onValueChange, step, value, lockMode]
[min, max, onValueChange, step]
);
const handleInputChange = React.useCallback(
(index: 0 | 1, inputValue: string) => {
const newValue = parseFloat(inputValue);
if (!isNaN(newValue)) {
// Default behavior (no lock mode)
if (!lockMode) {
const clampedValue: [number, number] = [...value];
clampedValue[index] = roundToStep(Math.min(Math.max(newValue, min), max));
if (index === 0 && clampedValue[0] > clampedValue[1]) {
clampedValue[1] = clampedValue[0];
} else if (index === 1 && clampedValue[1] < clampedValue[0]) {
clampedValue[0] = clampedValue[1];
}
setValue(clampedValue);
onValueChange?.(clampedValue);
return;
}
// Lock mode behavior
const centerPoint = (value[0] + value[1]) / 2;
const rangeWidth = value[1] - value[0];
const clampedValue: [number, number] = [...value];
// Calculate new value with constraints
const boundedNewValue = roundToStep(Math.min(Math.max(newValue, min), max));
if (index === 0) {
// Left thumb changed
const delta = boundedNewValue - value[0];
// Symmetric change: move right thumb in opposite direction
const newRight = value[1] - delta;
const boundedNewRight = roundToStep(Math.min(Math.max(newRight, min), max));
// If right would go out of bounds, adjust left too
if (newRight !== boundedNewRight) {
// Adjust to maintain the same width but respect bounds
if (boundedNewRight === max) {
clampedValue[0] = roundToStep(Math.max(max - rangeWidth, min));
clampedValue[1] = max;
} else if (boundedNewRight === min) {
clampedValue[0] = min;
clampedValue[1] = roundToStep(Math.min(min + rangeWidth, max));
}
} else {
clampedValue[0] = boundedNewValue;
clampedValue[1] = boundedNewRight;
}
} else {
// Right thumb changed
const delta = boundedNewValue - value[1];
// Symmetric change: move left thumb in opposite direction
const newLeft = value[0] - delta;
const boundedNewLeft = roundToStep(Math.min(Math.max(newLeft, min), max));
// If left would go out of bounds, adjust right too
if (newLeft !== boundedNewLeft) {
// Adjust to maintain the same width but respect bounds
if (boundedNewLeft === min) {
clampedValue[0] = min;
clampedValue[1] = roundToStep(Math.min(min + rangeWidth, max));
} else if (boundedNewLeft === max) {
clampedValue[0] = roundToStep(Math.max(max - rangeWidth, min));
clampedValue[1] = max;
}
} else {
clampedValue[0] = boundedNewLeft;
clampedValue[1] = boundedNewValue;
}
clampedValue[index] = roundToStep(Math.min(Math.max(newValue, min), max));
if (index === 0 && clampedValue[0] > clampedValue[1]) {
clampedValue[1] = clampedValue[0];
} else if (index === 1 && clampedValue[1] < clampedValue[0]) {
clampedValue[0] = clampedValue[1];
}
setValue(clampedValue);
onValueChange?.(clampedValue);
}
},
[value, min, max, onValueChange, step, lockMode]
[value, min, max, onValueChange, step]
);
const formatValue = (val: number) => {
@@ -344,14 +109,8 @@ const DoubleSlider = React.forwardRef<HTMLDivElement, DoubleSliderProps>(
value={value}
onValueChange={handleSliderChange}
>
<SliderPrimitive.Track
ref={trackRef}
className="bg-primary/30 relative h-1 w-full grow overflow-hidden rounded-full"
>
<SliderPrimitive.Range
className={cn('bg-primary absolute h-full', lockMode && 'cursor-grab')}
onPointerDown={handleRangePointerDown}
/>
<SliderPrimitive.Track className="bg-primary/30 relative h-1 w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="bg-primary absolute h-full" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="border-background bg-primary focus-visible:ring-ring block h-4 w-4 rounded-full border-2 shadow transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="border-background bg-primary focus-visible:ring-ring block h-4 w-4 rounded-full border-2 shadow transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50" />
@@ -1,5 +1,4 @@
import React, { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import {
DropdownMenu,
@@ -8,7 +7,9 @@ import {
DropdownMenuItem,
Icons,
Button,
ToolButton,
} from '../';
import { IconPresentationProvider } from '@ohif/ui-next';
import NavBar from '../NavBar';
@@ -51,72 +52,77 @@ function Header({
};
return (
<NavBar
isSticky={isSticky}
{...props}
<IconPresentationProvider
size="large"
IconContainer={ToolButton}
>
<div className="relative h-[48px] items-center">
<div className="absolute left-0 top-1/2 flex -translate-y-1/2 items-center">
<div
className={classNames(
'mr-3 inline-flex items-center',
isReturnEnabled && 'cursor-pointer'
)}
onClick={onClickReturn}
data-cy="return-to-work-list"
>
{isReturnEnabled && <Icons.ArrowLeft className="text-primary ml-1 h-7 w-7" />}
<div className="ml-1">
{WhiteLabeling?.createLogoComponentFn?.(React, props) || <Icons.OHIFLogo />}
<NavBar
isSticky={isSticky}
{...props}
>
<div className="relative h-[48px] items-center">
<div className="absolute left-0 top-1/2 flex -translate-y-1/2 items-center">
<div
className={classNames(
'mr-3 inline-flex items-center',
isReturnEnabled && 'cursor-pointer'
)}
onClick={onClickReturn}
data-cy="return-to-work-list"
>
{isReturnEnabled && <Icons.ArrowLeft className="text-primary ml-1 h-7 w-7" />}
<div className="ml-1">
{WhiteLabeling?.createLogoComponentFn?.(React, props) || <Icons.OHIFLogo />}
</div>
</div>
</div>
<div className="absolute top-1/2 left-[250px] h-8 -translate-y-1/2">{Secondary}</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 flex -translate-y-1/2 select-none items-center">
{UndoRedo}
<div className="border-primary-dark mx-1.5 h-[25px] border-r"></div>
{PatientInfo}
<div className="border-primary-dark mx-1.5 h-[25px] border-r"></div>
<div className="flex-shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-primary hover:bg-primary-dark mt-2 h-full w-full"
>
<Icons.GearSettings />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{menuOptions.map((option, index) => {
const IconComponent = option.icon
? Icons[option.icon as keyof typeof Icons]
: null;
return (
<DropdownMenuItem
key={index}
onSelect={option.onClick}
className="flex items-center gap-2 py-2"
>
{IconComponent && (
<span className="flex h-4 w-4 items-center justify-center">
<Icons.ByName name={IconComponent.name} />
</span>
)}
<span className="flex-1">{option.title}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<div className="absolute top-1/2 left-[250px] h-8 -translate-y-1/2">{Secondary}</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 flex -translate-y-1/2 select-none items-center">
{UndoRedo}
<div className="border-primary-dark mx-1.5 h-[25px] border-r"></div>
{PatientInfo}
<div className="border-primary-dark mx-1.5 h-[25px] border-r"></div>
<div className="flex-shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-primary hover:bg-primary-dark mt-2 h-full w-full"
>
<Icons.GearSettings />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{menuOptions.map((option, index) => {
const IconComponent = option.icon
? Icons[option.icon as keyof typeof Icons]
: null;
return (
<DropdownMenuItem
key={index}
onSelect={option.onClick}
className="flex items-center gap-2 py-2"
>
{IconComponent && (
<span className="flex h-4 w-4 items-center justify-center">
<Icons.ByName name={IconComponent.name} />
</span>
)}
<span className="flex-1">{option.title}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</NavBar>
</NavBar>
</IconPresentationProvider>
);
}
@@ -79,6 +79,9 @@ import OrientationSwitch from './Sources/OrientationSwitch';
import LayerBackground from './Sources/LayerBackground';
import LayerForeground from './Sources/LayerForeground';
import LayerSegmentation from './Sources/LayerSegmentation';
import WindowLevelAdvanced from './Sources/WindowLevelAdvanced';
import Opacity from './Sources/Opacity';
import Threshold from './Sources/Threshold';
import {
Tool3DRotate,
ToolAngle,
@@ -583,6 +586,9 @@ export const Icons = {
OHIFLogoColorDarkBackground,
Magnifier,
Pencil,
WindowLevelAdvanced,
Opacity,
Threshold,
//
//
//
@@ -3,16 +3,24 @@ import type { IconProps } from '../types';
export const Close = (props: IconProps) => (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M12 1.05 10.95 0 6 4.95 1.05 0 0 1.05 4.95 6 0 10.95 1.05 12 6 7.05 10.95 12 12 10.95 7.05 6z"
fill="currentColor"
fillRule="evenodd"
d="M8 8L16.19 16.19"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
/>
<path
d="M16.1904 8L8.00043 16.19"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
/>
</svg>
);
@@ -0,0 +1,106 @@
import React from 'react';
import type { IconProps } from '../types';
export const Opacity = (props: IconProps) => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect
x="5.5"
y="6"
width="13"
height="13"
rx="2.5"
stroke="currentColor"
/>
<g opacity="0.6">
<rect
x="15.5"
y="15.5"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="12.75"
y="15.5"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="10"
y="15.5"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="7.25"
y="15.5"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="15.5"
y="13"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="12.75"
y="13"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="10"
y="13"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="15.5"
y="10.5"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="12.75"
y="10.5"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
<rect
x="15.5"
y="8"
width="1.5"
height="1.5"
rx="0.75"
fill="currentColor"
/>
</g>
</svg>
);
export default Opacity;
@@ -0,0 +1,38 @@
import React from 'react';
import type { IconProps } from '../types';
export const Threshold = (props: IconProps) => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M18 17L15 17"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
/>
<path
d="M13 17H6"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
/>
<path
d="M13 19L13 15"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
/>
<path
d="M8.49512 13.0464C8.10547 13.0464 7.8689 12.7959 7.8689 12.3784V7.72119H6.52832C6.21753 7.72119 5.99023 7.49854 5.99023 7.19702C5.99023 6.89087 6.21753 6.66821 6.52832 6.66821H10.4666C10.782 6.66821 11.0046 6.88623 11.0046 7.19702C11.0046 7.50317 10.782 7.72119 10.4666 7.72119H9.12134V12.3784C9.12134 12.7913 8.88013 13.0464 8.49512 13.0464Z"
fill="currentColor"
/>
</svg>
);
export default Threshold;
@@ -0,0 +1,30 @@
import React from 'react';
import type { IconProps } from '../types';
export const WindowLevelAdvanced = (props: IconProps) => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M16.8555 8.05566L7.85547 17.0557"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
/>
<path
d="M6.30518 10.4448L5.06152 5.99316C5.02197 5.84375 5 5.68115 5 5.58447C5 5.2417 5.24609 5 5.59766 5C5.91846 5 6.11621 5.21533 6.21729 5.66797L7.07422 9.41211H7.13135L8.04541 5.66357C8.15527 5.20215 8.34863 5 8.7002 5C9.05615 5 9.25391 5.21094 9.36377 5.66357L10.2778 9.41211H10.335L11.1699 5.66797C11.2666 5.22412 11.4731 5 11.7983 5C12.1455 5 12.3872 5.2373 12.3872 5.57568C12.3872 5.67676 12.3696 5.83496 12.3433 5.93604L11.0996 10.4448C10.9722 10.9019 10.77 11.082 10.4053 11.082C10.0229 11.082 9.79004 10.8887 9.66699 10.4185L8.72656 6.86328H8.67822L7.73779 10.4448C7.62354 10.8755 7.38623 11.082 7.02148 11.082C6.66113 11.082 6.44141 10.9282 6.30518 10.4448Z"
fill="currentColor"
/>
<path
d="M18.2935 18.5503C18.2935 18.8535 18.0737 19.0557 17.7617 19.0557H15.2832C14.7515 19.0557 14.4922 18.8052 14.4922 18.2954V13.6548C14.4922 13.2505 14.7075 13.0132 15.0854 13.0132C15.459 13.0132 15.6787 13.2505 15.6787 13.6548V18.0449H17.7617C18.0737 18.0449 18.2935 18.2515 18.2935 18.5503Z"
fill="currentColor"
/>
</svg>
);
export default WindowLevelAdvanced;
@@ -29,7 +29,6 @@ interface NumericMetaContextValue {
min: number;
max: number;
step: number;
lockMode?: boolean;
}
const NumericMetaContext = createContext<NumericMetaContextValue | null>(null);
@@ -48,7 +47,6 @@ interface NumericMetaContainerProps {
max?: number;
step?: number;
className?: string;
lockMode?: boolean; // Add lock mode prop for doubleRange
}
function NumericMetaContainer({
@@ -62,7 +60,6 @@ function NumericMetaContainer({
max = 100,
step = 1,
className,
lockMode = false,
children,
}: PropsWithChildren<NumericMetaContainerProps>) {
// Calculate default values based on min and max
@@ -118,7 +115,6 @@ function NumericMetaContainer({
min,
max,
step,
lockMode,
}}
>
<div className={cn('flex flex-col', className)}>{children}</div>
@@ -240,7 +236,7 @@ function DoubleRange({ showNumberInputs, className }: DoubleRangeProps) {
throw new Error('DoubleRange must be used inside <Numeric.Container>.');
}
const { mode, doubleValue, setDoubleValue, min, max, step, lockMode } = ctx;
const { mode, doubleValue, setDoubleValue, min, max, step } = ctx;
const handleSliderChange = useCallback(
(values: [number, number]) => {
@@ -262,7 +258,6 @@ function DoubleRange({ showNumberInputs, className }: DoubleRangeProps) {
defaultValue={doubleValue}
onValueChange={handleSliderChange}
showNumberInputs={showNumberInputs}
lockMode={lockMode}
/>
</div>
);
@@ -119,7 +119,13 @@ const SelectItem = React.forwardRef<
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{typeof children === 'string' ? (
<SelectPrimitive.ItemText>
<span className="truncate">{children}</span>
</SelectPrimitive.ItemText>
) : (
children
)}
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
@@ -3,6 +3,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '../Tooltip';
import { Icons } from '../Icons';
import { Button } from '../Button';
import { cn } from '../../lib/utils';
import { useIconPresentation } from '../../contextProviders/IconPresentationProvider';
const baseClasses = '!rounded-lg inline-flex items-center justify-center';
const defaultClasses = 'bg-transparent text-foreground/80 hover:bg-background hover:text-highlight';
@@ -19,6 +20,10 @@ const sizeClasses = {
buttonSizeClass: 'w-8 h-8',
iconSizeClass: 'h-6 w-6',
},
tiny: {
buttonSizeClass: 'w-6 h-6',
iconSizeClass: 'h-4 w-4',
},
};
interface ToolButtonProps {
@@ -33,6 +38,7 @@ interface ToolButtonProps {
commands?: Record<string, unknown>;
onInteraction?: (details: { itemId: string; commands?: Record<string, unknown> }) => void;
className?: string;
children?: React.ReactNode;
}
function ToolButton(props: ToolButtonProps) {
@@ -48,8 +54,10 @@ function ToolButton(props: ToolButtonProps) {
commands,
onInteraction,
className,
children,
} = props;
const { className: iconClassName } = useIconPresentation();
const { buttonSizeClass, iconSizeClass } = sizeClasses[size] || sizeClasses.default;
const buttonClasses = cn(
@@ -90,10 +98,12 @@ function ToolButton(props: ToolButtonProps) {
aria-label={defaultTooltip}
disabled={disabled}
>
<Icons.ByName
name={icon}
className={iconSizeClass}
/>
{children || (
<Icons.ByName
name={icon}
className={iconClassName || iconSizeClass}
/>
)}
</Button>
</span>
</TooltipTrigger>
@@ -141,7 +141,6 @@ const ToolButtonListItem = React.forwardRef<
ToolButtonListItemProps
>(({ className, children, icon, disabledText, tooltip, disabled, ...props }, ref) => {
const defaultTooltip = tooltip || (typeof children === 'string' ? children : undefined);
const hasTooltip = defaultTooltip || disabledText;
const menuItem = (
<DropdownMenuItem
@@ -13,6 +13,10 @@ export enum ViewportActionCornersLocations {
topRight,
bottomLeft,
bottomRight,
topMiddle,
bottomMiddle,
leftMiddle,
rightMiddle,
}
const commonClasses = 'pointer-events-auto flex items-center';
@@ -27,11 +31,28 @@ const locationClasses = {
),
[ViewportActionCornersLocations.bottomLeft]: classNames(
commonClasses,
'absolute bottom-[4px] left-[0px] pl-[4px]'
'absolute bottom-[3px] left-[0px] pl-[4px]'
),
[ViewportActionCornersLocations.bottomRight]: classNames(
commonClasses,
'absolute bottom-[4px] right-[0px] right-viewport-scrollbar'
'absolute bottom-[3px] right-[16px] right-viewport-scrollbar'
),
[ViewportActionCornersLocations.topMiddle]: classNames(
commonClasses,
// Todo: to place on right side of the viewport orientation label
'absolute top-[25px] left-1/2 -translate-x-1/2'
),
[ViewportActionCornersLocations.bottomMiddle]: classNames(
commonClasses,
'absolute bottom-[3px] left-1/2 -translate-x-1/2'
),
[ViewportActionCornersLocations.leftMiddle]: classNames(
commonClasses,
'absolute left-[20px] top-[calc(50%+2px)] -translate-y-1/2 pl-[4px]'
),
[ViewportActionCornersLocations.rightMiddle]: classNames(
commonClasses,
'absolute right-[16px] top-1/2 -translate-y-1/2 right-viewport-scrollbar'
),
};
@@ -45,6 +66,10 @@ function Container({ children }: { children: ReactNode }) {
[ViewportActionCornersLocations.topRight]: null,
[ViewportActionCornersLocations.bottomLeft]: null,
[ViewportActionCornersLocations.bottomRight]: null,
[ViewportActionCornersLocations.topMiddle]: null,
[ViewportActionCornersLocations.bottomMiddle]: null,
[ViewportActionCornersLocations.leftMiddle]: null,
[ViewportActionCornersLocations.rightMiddle]: null,
});
const registerCorner = useCallback(
@@ -127,10 +152,30 @@ function BottomRight({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.bottomRight}>{children}</Corner>;
}
function TopMiddle({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.topMiddle}>{children}</Corner>;
}
function BottomMiddle({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.bottomMiddle}>{children}</Corner>;
}
function LeftMiddle({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.leftMiddle}>{children}</Corner>;
}
function RightMiddle({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.rightMiddle}>{children}</Corner>;
}
export const ViewportActionCorners = {
Container,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
TopMiddle,
BottomMiddle,
LeftMiddle,
RightMiddle,
};
@@ -14,7 +14,7 @@
top: 2.15rem;
}
.overlay-bottom {
bottom: 1rem;
bottom: 2.15rem;
}
.overlay-text {
@@ -0,0 +1,88 @@
import React, { createContext, useContext, ReactNode, ComponentType } from 'react';
import { cn } from '../utils';
import { Button } from '../components/Button';
export type IconSizeType = 'tiny' | 'small' | 'medium' | 'large' | number;
interface IconSizeContextType {
size: IconSizeType;
getSizeValue: (size?: IconSizeType) => number | string;
getSizeClassName: (size?: IconSizeType, additionalClasses?: string) => string;
IconContainer: ComponentType<any>;
containerProps: {
variant: string;
size: string;
[key: string]: any;
};
className: string;
}
const sizeMap = {
tiny: 16,
small: 20,
medium: 24,
large: 28,
};
export const getSizeValue = (size: IconSizeType = 'medium'): number | string => {
if (typeof size === 'number') {
return size;
}
return sizeMap[size] || sizeMap.medium;
};
export const getSizeClassName = (size: IconSizeType = 'medium', additionalClasses = ''): string => {
const sizeValue = getSizeValue(size);
return cn(`h-[${sizeValue}px] w-[${sizeValue}px]`, additionalClasses);
};
const defaultContext: IconSizeContextType = {
size: 'medium',
getSizeValue,
getSizeClassName,
IconContainer: Button,
containerProps: {
variant: 'ghost',
size: 'icon',
},
className: '',
};
export const IconSizeContext = createContext<IconSizeContextType>(defaultContext);
interface IconPresentationProviderProps {
size: IconSizeType;
children: ReactNode;
IconContainer?: ComponentType<any>;
containerProps?: {
variant?: string;
size?: string;
[key: string]: any;
};
}
export const IconPresentationProvider = ({
size,
children,
IconContainer = Button,
containerProps = {},
}: IconPresentationProviderProps) => {
const className = getSizeClassName(size);
const mergedProps = {
variant: 'ghost',
size: 'icon',
...containerProps,
};
const contextValue = {
size,
getSizeValue,
getSizeClassName,
IconContainer,
containerProps: mergedProps,
className,
};
return <IconSizeContext.Provider value={contextValue}>{children}</IconSizeContext.Provider>;
};
export const useIconPresentation = () => useContext(IconSizeContext);
@@ -1,550 +0,0 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useReducer,
ReactNode,
useMemo,
} from 'react';
import { ViewportActionCornersLocations } from '../components/Viewport/ViewportActionCorners';
import { ActionComponentInfo, AlignAndSide } from '../types';
const DEFAULT_STATE = {
viewports: {} as Record<string, Record<ViewportActionCornersLocations, ActionComponentInfo[]>>,
};
/**
* API for managing viewport action corner items.
* These methods control the state and behavior of the corner items themselves,
* not the content they might affect in the viewport.
*/
interface ViewportActionCornersApi {
getState: () => typeof DEFAULT_STATE;
addComponent: (component: ActionComponentInfo) => void;
addComponents: (components: Array<ActionComponentInfo>) => void;
clear: (viewportId: string) => void;
getAlignAndSide: (location: ViewportActionCornersLocations) => AlignAndSide;
lockItem: (viewportId: string, itemId: string) => void;
unlockItem: (viewportId: string, itemId: string) => void;
toggleLock: (viewportId: string, itemId: string) => void;
isItemLocked: (viewportId: string, itemId: string) => boolean;
showItem: (viewportId: string, itemId: string) => void;
hideItem: (viewportId: string, itemId: string) => void;
toggleVisibility: (viewportId: string, itemId: string) => void;
isItemVisible: (viewportId: string, itemId: string) => boolean;
openItem: (viewportId: string, itemId: string) => void;
closeItem: (viewportId: string, itemId: string) => void;
closeAllItems: (viewportId: string) => void;
isItemOpen: (viewportId: string, itemId: string) => boolean;
}
export const ViewportActionCornersContext = createContext<
[typeof DEFAULT_STATE, ViewportActionCornersApi]
>([DEFAULT_STATE, {} as ViewportActionCornersApi]);
interface ViewportActionCornersProviderProps {
children: ReactNode;
service: any;
}
export function ViewportActionCornersProvider({
children,
service,
}: ViewportActionCornersProviderProps) {
const viewportActionCornersReducer = (state = DEFAULT_STATE, action) => {
switch (action.type) {
case 'ADD_COMPONENT': {
const { viewportId, id, component, location, indexPriority = 0 } = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
newState.viewports[viewportId] = {
[ViewportActionCornersLocations.topLeft]: [],
[ViewportActionCornersLocations.topRight]: [],
[ViewportActionCornersLocations.bottomLeft]: [],
[ViewportActionCornersLocations.bottomRight]: [],
};
}
if (!newState.viewports[viewportId][location]) {
newState.viewports[viewportId][location] = [];
}
const componentInfo = {
id,
component,
indexPriority,
isOpen: false,
isVisible: true,
isLocked: false,
};
const components = [...newState.viewports[viewportId][location]];
const index = components.findIndex(item => item.indexPriority > indexPriority);
if (index === -1) {
components.push(componentInfo);
} else {
components.splice(index, 0, componentInfo);
}
newState.viewports[viewportId][location] = components;
return newState;
}
case 'ADD_COMPONENTS': {
const components = action.payload;
let newState = { ...state };
components.forEach(component => {
newState = viewportActionCornersReducer(newState, {
type: 'ADD_COMPONENT',
payload: component,
});
});
return newState;
}
case 'CLEAR': {
const viewportId = action.payload;
const newState = { ...state };
if (newState.viewports[viewportId]) {
const newViewports = { ...newState.viewports };
delete newViewports[viewportId];
newState.viewports = newViewports;
}
return newState;
}
case 'SET_LOCKED': {
const { viewportId, itemId, lockedStatus } = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
return state;
}
const viewportCopy = { ...newState.viewports[viewportId] };
Object.keys(viewportCopy).forEach(locationKey => {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = [...viewportCopy[location]];
const itemIndex = components.findIndex(item => item.id === itemId);
if (itemIndex !== -1) {
const updatedItem = { ...components[itemIndex], isLocked: lockedStatus };
components[itemIndex] = updatedItem;
viewportCopy[location] = components;
}
});
newState.viewports[viewportId] = viewportCopy;
return newState;
}
case 'SET_VISIBLE': {
const { viewportId, itemId, visibleStatus } = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
return state;
}
const viewportCopy = { ...newState.viewports[viewportId] };
Object.keys(viewportCopy).forEach(locationKey => {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = [...viewportCopy[location]];
const itemIndex = components.findIndex(item => item.id === itemId);
if (itemIndex !== -1) {
const updatedItem = { ...components[itemIndex], isVisible: visibleStatus };
components[itemIndex] = updatedItem;
viewportCopy[location] = components;
}
});
newState.viewports[viewportId] = viewportCopy;
return newState;
}
case 'OPEN_ITEM': {
const { viewportId, itemId } = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
return state;
}
const viewportCopy = { ...newState.viewports[viewportId] };
// Update isOpen flag for the component with matching id in any location
Object.keys(viewportCopy).forEach(locationKey => {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = [...viewportCopy[location]];
const itemIndex = components.findIndex(item => item.id === itemId);
if (itemIndex !== -1) {
const updatedItem = { ...components[itemIndex], isOpen: true };
components[itemIndex] = updatedItem;
viewportCopy[location] = components;
}
});
newState.viewports[viewportId] = viewportCopy;
return newState;
}
case 'CLOSE_ITEM': {
const { viewportId, itemId } = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
return state;
}
const viewportCopy = { ...newState.viewports[viewportId] };
// Update isOpen flag for the component with matching id in any location
Object.keys(viewportCopy).forEach(locationKey => {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = [...viewportCopy[location]];
const itemIndex = components.findIndex(item => item.id === itemId);
if (itemIndex !== -1) {
const updatedItem = { ...components[itemIndex], isOpen: false };
components[itemIndex] = updatedItem;
viewportCopy[location] = components;
}
});
newState.viewports[viewportId] = viewportCopy;
return newState;
}
case 'CLOSE_ALL_ITEMS': {
const viewportId = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
return state;
}
const viewportCopy = { ...newState.viewports[viewportId] };
// Set isOpen to false for all components in the viewport
Object.keys(viewportCopy).forEach(locationKey => {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = viewportCopy[location].map(item => ({
...item,
isOpen: false,
}));
viewportCopy[location] = components;
});
newState.viewports[viewportId] = viewportCopy;
return newState;
}
default:
return state;
}
};
const [state, dispatch] = useReducer(viewportActionCornersReducer, DEFAULT_STATE);
const getState = useCallback(() => {
return state;
}, [state]);
const addComponent = useCallback(
(component: ActionComponentInfo) => {
dispatch({
type: 'ADD_COMPONENT',
payload: component,
});
},
[dispatch]
);
const addComponents = useCallback(
(components: Array<ActionComponentInfo>) => {
dispatch({
type: 'ADD_COMPONENTS',
payload: components,
});
},
[dispatch]
);
const clear = useCallback(
(viewportId: string) => {
dispatch({
type: 'CLEAR',
payload: viewportId,
});
},
[dispatch]
);
const lockItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'SET_LOCKED',
payload: { viewportId, itemId, lockedStatus: true },
});
},
[dispatch]
);
const unlockItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'SET_LOCKED',
payload: { viewportId, itemId, lockedStatus: false },
});
},
[dispatch]
);
const toggleLock = useCallback(
(viewportId: string, itemId: string) => {
const currentLocked = isItemLocked(viewportId, itemId);
dispatch({
type: 'SET_LOCKED',
payload: { viewportId, itemId, lockedStatus: !currentLocked },
});
},
[dispatch]
);
const isItemLocked = useCallback(
(viewportId: string, itemId: string) => {
if (!state.viewports[viewportId]) {
return false; // Default to unlocked if viewport doesn't exist
}
for (const locationKey in state.viewports[viewportId]) {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = state.viewports[viewportId][location];
const item = components.find(item => item.id === itemId);
if (item && item.isLocked === true) {
return true;
}
}
return false; // Default to unlocked if item not found or isLocked is undefined
},
[state.viewports]
);
const showItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'SET_VISIBLE',
payload: { viewportId, itemId, visibleStatus: true },
});
},
[dispatch]
);
const hideItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'SET_VISIBLE',
payload: { viewportId, itemId, visibleStatus: false },
});
},
[dispatch]
);
const toggleVisibility = useCallback(
(viewportId: string, itemId: string) => {
const currentVisible = isItemVisible(viewportId, itemId);
dispatch({
type: 'SET_VISIBLE',
payload: { viewportId, itemId, visibleStatus: !currentVisible },
});
},
[dispatch]
);
const isItemVisible = useCallback(
(viewportId: string, itemId: string) => {
if (!state.viewports[viewportId]) {
return true; // Default to visible if viewport doesn't exist
}
for (const locationKey in state.viewports[viewportId]) {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = state.viewports[viewportId][location];
const item = components.find(item => item.id === itemId);
if (item && item.isVisible === false) {
return false;
}
}
return true; // Default to visible if item not found or isVisible is undefined
},
[state.viewports]
);
const openItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'OPEN_ITEM',
payload: { viewportId, itemId },
});
},
[dispatch]
);
const closeItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'CLOSE_ITEM',
payload: { viewportId, itemId },
});
},
[dispatch]
);
const closeAllItems = useCallback(
(viewportId: string) => {
dispatch({
type: 'CLOSE_ALL_ITEMS',
payload: viewportId,
});
},
[dispatch]
);
const isItemOpen = useCallback(
(viewportId: string, itemId: string) => {
if (!state.viewports[viewportId]) {
return false;
}
for (const locationKey in state.viewports[viewportId]) {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = state.viewports[viewportId][location];
const item = components.find(item => item.id === itemId);
if (item && item.isOpen === true) {
return true;
}
}
return false;
},
[state.viewports]
);
const getAlignAndSide = useCallback(
(location: ViewportActionCornersLocations) => {
return service.getAlignAndSide(location);
},
[service]
);
const api = useMemo(() => {
return {
getState,
addComponent,
addComponents,
clear,
getAlignAndSide,
lockItem,
unlockItem,
toggleLock,
isItemLocked,
showItem,
hideItem,
toggleVisibility,
isItemVisible,
openItem,
closeItem,
closeAllItems,
isItemOpen,
};
}, [
getState,
addComponent,
addComponents,
clear,
getAlignAndSide,
lockItem,
unlockItem,
toggleLock,
isItemLocked,
showItem,
hideItem,
toggleVisibility,
isItemVisible,
openItem,
closeItem,
closeAllItems,
isItemOpen,
]);
useEffect(() => {
if (service && service.setServiceImplementation) {
const implementation = {
getState,
addComponent,
addComponents,
clear,
lockItem,
unlockItem,
toggleLock,
isItemLocked,
showItem,
hideItem,
toggleVisibility,
isItemVisible,
openItem,
closeItem,
closeAllItems,
isItemOpen,
};
service.setServiceImplementation(implementation);
}
}, [
service,
getState,
addComponent,
addComponents,
clear,
lockItem,
unlockItem,
toggleLock,
isItemLocked,
showItem,
hideItem,
toggleVisibility,
isItemVisible,
openItem,
closeItem,
closeAllItems,
isItemOpen,
]);
return (
<ViewportActionCornersContext.Provider value={[state, api]}>
{children}
</ViewportActionCornersContext.Provider>
);
}
// Custom hook to use the ViewportActionCorners context
export const useViewportActionCorners = () => useContext(ViewportActionCornersContext);
@@ -9,10 +9,6 @@ import { UserAuthenticationProvider, useUserAuthentication } from './UserAuthent
import { ImageViewerContext, ImageViewerProvider, useImageViewer } from './ImageViewerProvider';
import DragAndDropProvider from './DragAndDropProvider';
import CineProvider, { useCine } from './CineProvider';
import {
ViewportActionCornersProvider,
useViewportActionCorners,
} from './ViewportActionCornersProvider';
export { useNotification, NotificationProvider };
export { ViewportGridContext, ViewportGridProvider, useViewportGrid };
@@ -24,4 +20,4 @@ export { UserAuthenticationProvider, useUserAuthentication };
export { ImageViewerContext, ImageViewerProvider, useImageViewer };
export { DragAndDropProvider };
export { CineProvider, useCine };
export { ViewportActionCornersProvider, useViewportActionCorners };
export { IconPresentationProvider, useIconPresentation } from './IconPresentationProvider';