feat(app): Decouple colorbar UI from service and enable action menu programatic control (#5018)

This commit is contained in:
Alireza authored and GitHub committed 2025-05-08 12:51:30 -04:00
1 parent f14d9ebdd7
commit 1445e12124
41 files changed
+1872 -659

No files matched your search

@@ -1,11 +1,13 @@
import React from 'react';
import React, {
createContext,
useContext,
ReactNode,
useState,
useEffect,
useCallback,
} from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
/**
* A small container that can render multiple "corner" items (like icons, status)
* in each corner of the viewport: top-left, top-right, bottom-left, bottom-right.
*/
export enum ViewportActionCornersLocations {
topLeft,
topRight,
@@ -33,35 +35,102 @@ const locationClasses = {
),
};
function ViewportActionCorners({ cornerComponents }) {
if (!cornerComponents) {
return null;
}
const ViewportActionCornersContext = createContext<{
registerCorner: (location: ViewportActionCornersLocations, children: ReactNode) => void;
} | null>(null);
function Container({ children }: { children: ReactNode }) {
const [corners, setCorners] = useState({
[ViewportActionCornersLocations.topLeft]: null,
[ViewportActionCornersLocations.topRight]: null,
[ViewportActionCornersLocations.bottomLeft]: null,
[ViewportActionCornersLocations.bottomRight]: null,
});
const registerCorner = useCallback(
(location: ViewportActionCornersLocations, children: ReactNode) => {
setCorners(prev => {
// Only update if the children are different to avoid unnecessary renders
if (prev[location] === children) {
return prev;
}
return {
...prev,
[location]: children,
};
});
},
[]
);
return (
<div
className="pointer-events-none absolute h-full w-full select-none"
onDoubleClick={event => {
event.preventDefault();
event.stopPropagation();
}}
>
{Object.entries(cornerComponents).map(([location, locationArray]) => (
<div
key={location}
className={locationClasses[location]}
>
{locationArray.map(componentInfo => (
<div key={componentInfo.id}>{componentInfo.component}</div>
))}
</div>
))}
</div>
<ViewportActionCornersContext.Provider value={{ registerCorner }}>
{children}
<div
className="pointer-events-none absolute h-full w-full select-none"
onDoubleClick={event => {
event.preventDefault();
event.stopPropagation();
}}
>
{Object.entries(corners).map(([location, children]) => {
if (!children) {
return null;
}
return (
<div
key={location}
className={locationClasses[location]}
>
{children}
</div>
);
})}
</div>
</ViewportActionCornersContext.Provider>
);
}
ViewportActionCorners.propTypes = {
cornerComponents: PropTypes.object.isRequired,
};
function Corner({
location,
children,
}: {
location: ViewportActionCornersLocations;
children: ReactNode;
}) {
const context = useContext(ViewportActionCornersContext);
export { ViewportActionCorners };
if (!context) {
throw new Error('Corner component must be used within a ViewportActionCorners.Container');
}
useEffect(() => {
context.registerCorner(location, children);
}, [context, location, children]);
return null;
}
function TopLeft({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.topLeft}>{children}</Corner>;
}
function TopRight({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.topRight}>{children}</Corner>;
}
function BottomLeft({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.bottomLeft}>{children}</Corner>;
}
function BottomRight({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.bottomRight}>{children}</Corner>;
}
export const ViewportActionCorners = {
Container,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
};
@@ -0,0 +1,550 @@
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,6 +9,10 @@ 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 };
@@ -20,3 +24,4 @@ export { UserAuthenticationProvider, useUserAuthentication };
export { ImageViewerContext, ImageViewerProvider, useImageViewer };
export { DragAndDropProvider };
export { CineProvider, useCine };
export { ViewportActionCornersProvider, useViewportActionCorners };
@@ -0,0 +1,18 @@
import { ReactNode } from 'react';
import { ViewportActionCornersLocations } from '../components/Viewport/ViewportActionCorners';
export type ActionComponentInfo = {
viewportId: string;
id: string;
component: ReactNode;
location: ViewportActionCornersLocations;
indexPriority?: number;
isLocked?: boolean;
isOpen?: boolean;
isVisible?: boolean;
};
export type AlignAndSide = {
align: 'start' | 'end' | 'center';
side: 'top' | 'bottom' | 'left' | 'right';
};
+1
View File
@@ -5,6 +5,7 @@ import ThumbnailType from './ThumbnailType';
export * from './Predicate';
export * from './ContextMenuItem';
export * from './ViewportActionCornersTypes';
export * from './ActionCorners';
/**
* StringNumber often comes back from DICOMweb for integer valued items.