diff --git a/extensions/cornerstone/src/components/CinePlayer/CinePlayer.tsx b/extensions/cornerstone/src/components/CinePlayer/CinePlayer.tsx index 856d9ec63..6ea2fe07d 100644 --- a/extensions/cornerstone/src/components/CinePlayer/CinePlayer.tsx +++ b/extensions/cornerstone/src/components/CinePlayer/CinePlayer.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState, useRef } from 'react'; -import { useCine } from '@ohif/ui'; +import { useCine } from '@ohif/ui-next'; import { Enums, eventTarget, cache } from '@cornerstonejs/core'; import { useAppConfig } from '@state'; @@ -244,4 +244,4 @@ function RenderCinePlayer({ ); } -export default WrappedCinePlayer; +export default WrappedCinePlayer; \ No newline at end of file diff --git a/extensions/cornerstone/src/customizations/miscCustomization.ts b/extensions/cornerstone/src/customizations/miscCustomization.ts index fe7d89bff..073e38921 100644 --- a/extensions/cornerstone/src/customizations/miscCustomization.ts +++ b/extensions/cornerstone/src/customizations/miscCustomization.ts @@ -1,4 +1,4 @@ -import { CinePlayer } from '@ohif/ui'; +import { CinePlayer } from '@ohif/ui-next'; import DicomUpload from '../components/DicomUpload/DicomUpload'; export default { diff --git a/platform/app/src/App.tsx b/platform/app/src/App.tsx index 2160ea539..5dc1b439a 100644 --- a/platform/app/src/App.tsx +++ b/platform/app/src/App.tsx @@ -14,17 +14,13 @@ import { ServiceProvidersManager, SystemContextProvider, } from '@ohif/core'; -import { - ThemeWrapper, - ViewportDialogProvider, - CineProvider, - UserAuthenticationProvider, -} from '@ohif/ui'; +import { ThemeWrapper, ViewportDialogProvider, UserAuthenticationProvider } from '@ohif/ui'; import { ThemeWrapper as ThemeWrapperNext, NotificationProvider, ViewportGridProvider, DialogProvider, + CineProvider, TooltipProvider, Modal as ModalNext, ManagedDialog, diff --git a/platform/ui-next/src/components/CinePlayer/CinePlayer.tsx b/platform/ui-next/src/components/CinePlayer/CinePlayer.tsx new file mode 100644 index 000000000..74275c9ba --- /dev/null +++ b/platform/ui-next/src/components/CinePlayer/CinePlayer.tsx @@ -0,0 +1,199 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import debounce from 'lodash.debounce'; + +import { Icons } from '@ohif/ui-next'; +import { Popover, PopoverContent, PopoverTrigger } from '../Popover/Popover'; +import { Button } from '../Button/Button'; +import { Numeric } from '../Numeric/Numeric'; + +export type CinePlayerProps = { + className: string; + isPlaying: boolean; + minFrameRate?: number; + maxFrameRate?: number; + stepFrameRate?: number; + frameRate?: number; + onFrameRateChange: (value: number) => void; + onPlayPauseChange: (value: boolean) => void; + onClose: () => void; + updateDynamicInfo?: (info: any) => void; + dynamicInfo?: { + dimensionGroupNumber: number; + numDimensionGroups: number; + label?: string; + }; +}; + +const CinePlayer: React.FC = ({ + className, + isPlaying = false, + minFrameRate = 1, + maxFrameRate = 90, + stepFrameRate = 1, + frameRate: defaultFrameRate = 24, + onFrameRateChange = () => {}, + onPlayPauseChange = () => {}, + onClose = () => {}, + dynamicInfo = {}, + updateDynamicInfo, +}) => { + const isDynamic = !!dynamicInfo?.numDimensionGroups; + const [frameRate, setFrameRate] = useState(defaultFrameRate); + const [popoverOpen, setPopoverOpen] = useState(false); + const debouncedSetFrameRate = useCallback(debounce(onFrameRateChange, 100), [onFrameRateChange]); + + const getPlayPauseIconName = () => (isPlaying ? 'icon-pause' : 'icon-play'); + + const handleSetFrameRate = (frameRate: number) => { + if (frameRate < minFrameRate || frameRate > maxFrameRate) { + return; + } + setFrameRate(frameRate); + debouncedSetFrameRate(frameRate); + }; + + useEffect(() => { + setFrameRate(defaultFrameRate); + }, [defaultFrameRate]); + + const handleDimensionGroupNumberChange = useCallback( + (newGroupNumber: number) => { + if (isDynamic && dynamicInfo) { + updateDynamicInfo?.({ + ...dynamicInfo, + dimensionGroupNumber: newGroupNumber, + }); + } + }, + [isDynamic, dynamicInfo, updateDynamicInfo] + ); + + return ( +
+ {isDynamic && dynamicInfo && ( + handleDimensionGroupNumberChange(val as number)} + className="mb-3 w-full" + > + + + )} +
+ + + {isDynamic && dynamicInfo && ( +
+
+ {dynamicInfo.dimensionGroupNumber}{' '} + {`/${dynamicInfo.numDimensionGroups}`} +
+
{dynamicInfo.label}
+
+ )} + +
+ + + + + + handleSetFrameRate(val as number)} + className="h-6 px-2" + > + + + + +
+ + +
+
+ ); +}; + +CinePlayer.propTypes = { + /** Minimum value for range slider */ + minFrameRate: PropTypes.number, + /** Maximum value for range slider */ + maxFrameRate: PropTypes.number, + /** Increment range slider can "step" in either direction */ + stepFrameRate: PropTypes.number, + frameRate: PropTypes.number, + /** 'true' if playing, 'false' if paused */ + isPlaying: PropTypes.bool.isRequired, + onPlayPauseChange: PropTypes.func, + onFrameRateChange: PropTypes.func, + onClose: PropTypes.func, + isDynamic: PropTypes.bool, + dynamicInfo: PropTypes.shape({ + dimensionGroupNumber: PropTypes.number, + numDimensionGroups: PropTypes.number, + label: PropTypes.string, + }), +}; + +export default CinePlayer; diff --git a/platform/ui-next/src/components/CinePlayer/index.ts b/platform/ui-next/src/components/CinePlayer/index.ts new file mode 100644 index 000000000..c5fac3d8c --- /dev/null +++ b/platform/ui-next/src/components/CinePlayer/index.ts @@ -0,0 +1,5 @@ +import CinePlayer from './CinePlayer'; +import type { CinePlayerProps } from './CinePlayer'; + +export { CinePlayer, CinePlayerProps }; +export default CinePlayer; \ No newline at end of file diff --git a/platform/ui-next/src/components/Numeric/Numeric.tsx b/platform/ui-next/src/components/Numeric/Numeric.tsx index dc1e6e081..718daf1b0 100644 --- a/platform/ui-next/src/components/Numeric/Numeric.tsx +++ b/platform/ui-next/src/components/Numeric/Numeric.tsx @@ -312,10 +312,11 @@ interface NumberStepperProps { className?: string; children?: React.ReactNode; direction?: 'horizontal' | 'vertical'; + inputWidth?: string; } // Modified NumberStepper component to properly position left/right controls -function NumberStepper({ className, children, direction }: NumberStepperProps) { +function NumberStepper({ className, children, direction, inputWidth }: NumberStepperProps) { const ctx = useContext(NumericMetaContext); if (!ctx) { throw new Error('NumberStepper must be used inside .'); @@ -379,7 +380,10 @@ function NumberStepper({ className, children, direction }: NumberStepperProps) { value={displayValue} onChange={handleInputChange} onBlur={handleBlur} - className="h-6 flex-1 appearance-none border-none p-0 text-center shadow-none focus:border-none focus:outline-none" + className={cn( + "h-6 appearance-none border-none p-0 text-center shadow-none focus:border-none focus:outline-none", + inputWidth ? inputWidth : "w-12 max-w-12" + )} /> {children}
diff --git a/platform/ui-next/src/components/index.ts b/platform/ui-next/src/components/index.ts index 9e660144a..5c83800ef 100644 --- a/platform/ui-next/src/components/index.ts +++ b/platform/ui-next/src/components/index.ts @@ -29,6 +29,7 @@ import { Combobox } from './Combobox'; import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './Popover'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from './Resizable'; import { Calendar } from './Calendar'; +import CinePlayer from './CinePlayer'; import { DatePickerWithRange } from './DateRange'; import { Separator } from './Separator'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './Tabs'; @@ -254,4 +255,5 @@ export { InputFilter, WindowLevel, WindowLevelHistogram, -}; + CinePlayer, +}; \ No newline at end of file diff --git a/platform/ui-next/src/contextProviders/CineProvider.tsx b/platform/ui-next/src/contextProviders/CineProvider.tsx new file mode 100644 index 000000000..1ba220caf --- /dev/null +++ b/platform/ui-next/src/contextProviders/CineProvider.tsx @@ -0,0 +1,103 @@ +import React, { createContext, useCallback, useContext, useEffect, useReducer } from 'react'; + +const DEFAULT_STATE = { + isCineEnabled: false, + cines: { + /* + * viewportId: { isPlaying: false, frameRate: 24 }; + */ + }, +}; + +const DEFAULT_CINE = { isPlaying: false, frameRate: 24 }; + +export const CineContext = createContext(null); + +export default function CineProvider({ children, service }) { + const reducer = (state, action) => { + switch (action.type) { + case 'SET_CINE': { + const { id, frameRate, isPlaying = undefined } = action.payload; + const cines = state.cines; + + const syncedCineIds = service.getSyncedViewports(id).map(({ viewportId }) => viewportId); + const cineIdsToUpdate = [id, ...syncedCineIds].filter(curId => { + const currentCine = cines[curId] ?? {}; + const shouldUpdateFrameRate = + currentCine.frameRate !== (frameRate ?? currentCine.frameRate); + const shouldUpdateIsPlaying = + currentCine.isPlaying !== (isPlaying ?? currentCine.isPlaying); + + return shouldUpdateFrameRate || shouldUpdateIsPlaying; + }); + + cineIdsToUpdate.forEach(currId => { + let cine = cines[currId]; + + if (!cine) { + cine = { id, ...DEFAULT_CINE }; + cines[currId] = cine; + } + + cine.frameRate = frameRate ?? cine.frameRate; + cine.isPlaying = isPlaying ?? cine.isPlaying; + }); + + return { ...state, ...cines }; + } + case 'SET_IS_CINE_ENABLED': { + return { ...state, ...{ isCineEnabled: action.payload } }; + } + default: + return action.payload; + } + }; + + const [state, dispatch] = useReducer(reducer, DEFAULT_STATE); + + const getState = useCallback(() => state, [state]); + + const setIsCineEnabled = useCallback( + isCineEnabled => dispatch({ type: 'SET_IS_CINE_ENABLED', payload: isCineEnabled }), + [dispatch] + ); + + const setCine = useCallback( + ({ id, frameRate, isPlaying }) => + dispatch({ + type: 'SET_CINE', + payload: { + id, + frameRate, + isPlaying, + }, + }), + [dispatch] + ); + + /** + * Sets the implementation of a modal service that can be used by extensions. + * + * @returns void + */ + useEffect(() => { + if (service) { + service.setServiceImplementation({ getState, setIsCineEnabled, setCine }); + } + }, [getState, service, setCine, setIsCineEnabled]); + + const api = { + getState, + setCine, + setIsCineEnabled: isCineEnabled => service.setIsCineEnabled(isCineEnabled), + playClip: (element, playClipOptions) => service.playClip(element, playClipOptions), + stopClip: (element, stopClipOptions) => service.stopClip(element, stopClipOptions), + setViewportCineClosed: viewportId => service.setViewportCineClosed(viewportId), + clearViewportCineClosed: viewportId => service.clearViewportCineClosed(viewportId), + isViewportCineClosed: viewportId => service.isViewportCineClosed(viewportId), + }; + + return {children}; +} + +export const useCine = () => useContext(CineContext); \ No newline at end of file diff --git a/platform/ui-next/src/contextProviders/index.ts b/platform/ui-next/src/contextProviders/index.ts index 61140c085..a14f89349 100644 --- a/platform/ui-next/src/contextProviders/index.ts +++ b/platform/ui-next/src/contextProviders/index.ts @@ -3,9 +3,11 @@ import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './Vi import { ModalProvider, useModal } from './ModalProvider'; import { DialogProvider, useDialog } from './DialogProvider'; import ManagedDialog from './ManagedDialog'; +import CineProvider, { useCine } from './CineProvider'; export { useNotification, NotificationProvider }; export { ViewportGridContext, ViewportGridProvider, useViewportGrid }; export { ModalProvider, useModal }; export { DialogProvider, useDialog }; export { ManagedDialog }; +export { CineProvider, useCine }; \ No newline at end of file