From 11ec5e4d9cb5e301a784b61aa4f86b777e208f38 Mon Sep 17 00:00:00 2001 From: Damian Date: Fri, 19 Dec 2025 14:37:51 +0100 Subject: [PATCH] feat: Adjust the OHIF Viewer to support events related to the custom toolbar [CWLF-390] --- .../Viewport/Overlays/CornerstoneOverlays.tsx | 4 +- extensions/cornerstone/src/commandsModule.ts | 2 +- .../viewportOverlayCustomization.tsx | 65 +-- .../default/src/DicomWebDataSource/index.ts | 15 +- .../default/src/DicomWebDataSource/qido.js | 2 +- extensions/default/src/ViewerLayout/index.tsx | 23 +- .../default/src/utils/callInputDialog.tsx | 39 +- modes/basic/src/index.tsx | 16 +- modes/basic/src/initToolGroups.ts | 48 +- package.json | 2 +- .../app/.webpack/writePluginImportsFile.js | 14 +- platform/app/public/config/butterfly.js | 158 +++++++ platform/app/public/config/default.js | 3 +- platform/app/src/App.tsx | 12 +- .../app/src/butterfly/ButterflyProvider.tsx | 439 ++++++++++++++++++ platform/app/src/components/ViewportGrid.tsx | 2 +- .../OHIFDialogs/InlineAnnotationInput.tsx | 94 ++++ .../src/components/OHIFDialogs/index.ts | 1 + .../src/components/Viewport/ViewportPane.tsx | 8 +- platform/ui-next/src/components/index.ts | 5 +- 20 files changed, 831 insertions(+), 121 deletions(-) create mode 100644 platform/app/public/config/butterfly.js create mode 100644 platform/app/src/butterfly/ButterflyProvider.tsx create mode 100644 platform/ui-next/src/components/OHIFDialogs/InlineAnnotationInput.tsx diff --git a/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx b/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx index 43ec800c455..d00e241cff3 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import ViewportImageScrollbar from './ViewportImageScrollbar'; import CustomizableViewportOverlay from './CustomizableViewportOverlay'; -import ViewportOrientationMarkers from './ViewportOrientationMarkers'; +// import ViewportOrientationMarkers from './ViewportOrientationMarkers'; import ViewportImageSliceLoadingIndicator from './ViewportImageSliceLoadingIndicator'; function CornerstoneOverlays(props: withAppTypes) { @@ -68,6 +68,7 @@ function CornerstoneOverlays(props: withAppTypes) { element={element} /> + {/* Orientation markers disabled + */} ); } diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index 53d987ceb84..37f2ddc4354 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -805,7 +805,7 @@ function commandsModule({ const label = await callInputDialog({ uiDialogService, title: i18n.t('Tools:Edit Arrow Text'), - placeholder: data?.data?.label || i18n.t('Tools:Enter new text'), + placeholder: data?.data?.label || i18n.t('Tools:Enter annotation text'), defaultValue: data?.data?.label || '', }); diff --git a/extensions/cornerstone/src/customizations/viewportOverlayCustomization.tsx b/extensions/cornerstone/src/customizations/viewportOverlayCustomization.tsx index 9d3e2e1b244..7b13dfd1486 100644 --- a/extensions/cornerstone/src/customizations/viewportOverlayCustomization.tsx +++ b/extensions/cornerstone/src/customizations/viewportOverlayCustomization.tsx @@ -1,24 +1,25 @@ export default { 'viewportOverlay.topLeft': [ - { - id: 'StudyDate', - inheritsFrom: 'ohif.overlayItem', - label: '', - title: 'Study date', - condition: ({ referenceInstance }) => referenceInstance?.StudyDate, - contentF: ({ referenceInstance, formatters: { formatDate } }) => - formatDate(referenceInstance.StudyDate), - }, - { - id: 'SeriesDescription', - inheritsFrom: 'ohif.overlayItem', - label: '', - title: 'Series description', - condition: ({ referenceInstance }) => { - return referenceInstance && referenceInstance.SeriesDescription; - }, - contentF: ({ referenceInstance }) => referenceInstance.SeriesDescription, - }, + // Study date and series description disabled + // { + // id: 'StudyDate', + // inheritsFrom: 'ohif.overlayItem', + // label: '', + // title: 'Study date', + // condition: ({ referenceInstance }) => referenceInstance?.StudyDate, + // contentF: ({ referenceInstance, formatters: { formatDate } }) => + // formatDate(referenceInstance.StudyDate), + // }, + // { + // id: 'SeriesDescription', + // inheritsFrom: 'ohif.overlayItem', + // label: '', + // title: 'Series description', + // condition: ({ referenceInstance }) => { + // return referenceInstance && referenceInstance.SeriesDescription; + // }, + // contentF: ({ referenceInstance }) => referenceInstance.SeriesDescription, + // }, ], 'viewportOverlay.topRight': [], 'viewportOverlay.bottomLeft': [ @@ -26,19 +27,21 @@ export default { id: 'WindowLevel', inheritsFrom: 'ohif.overlayItem.windowLevel', }, - { - id: 'ZoomLevel', - inheritsFrom: 'ohif.overlayItem.zoomLevel', - condition: props => { - const activeToolName = props.toolGroupService.getActiveToolForViewport(props.viewportId); - return activeToolName === 'Zoom'; - }, - }, + // Zoom level disabled + // { + // id: 'ZoomLevel', + // inheritsFrom: 'ohif.overlayItem.zoomLevel', + // condition: props => { + // const activeToolName = props.toolGroupService.getActiveToolForViewport(props.viewportId); + // return activeToolName === 'Zoom'; + // }, + // }, ], 'viewportOverlay.bottomRight': [ - { - id: 'InstanceNumber', - inheritsFrom: 'ohif.overlayItem.instanceNumber', - }, + // Instance number disabled + // { + // id: 'InstanceNumber', + // inheritsFrom: 'ohif.overlayItem.instanceNumber', + // }, ], }; diff --git a/extensions/default/src/DicomWebDataSource/index.ts b/extensions/default/src/DicomWebDataSource/index.ts index 5fb351767d8..acdcb370bdd 100644 --- a/extensions/default/src/DicomWebDataSource/index.ts +++ b/extensions/default/src/DicomWebDataSource/index.ts @@ -16,7 +16,7 @@ import { retrieveStudyMetadata, deleteStudyMetadataPromise } from './retrieveStu import StaticWadoClient from './utils/StaticWadoClient'; import getDirectURL from '../utils/getDirectURL'; import { fixBulkDataURI } from './utils/fixBulkDataURI'; -import {HeadersInterface} from '@ohif/core/src/types/RequestHeaders'; +import { HeadersInterface } from '@ohif/core/src/types/RequestHeaders'; const { DicomMetaDictionary, DicomDict } = dcmjs.data; @@ -143,10 +143,13 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { dicomWebConfigCopy = JSON.parse(JSON.stringify(dicomWebConfig)); getAuthorizationHeader = () => { - const xhrRequestHeaders: HeadersInterface = {}; + let xhrRequestHeaders: HeadersInterface = {}; const authHeaders = userAuthenticationService.getAuthorizationHeader(); - if (authHeaders && authHeaders.Authorization) { - xhrRequestHeaders.Authorization = authHeaders.Authorization; + if (authHeaders) { + xhrRequestHeaders = { + ...xhrRequestHeaders, + ...authHeaders, + }; } return xhrRequestHeaders; }; @@ -158,7 +161,7 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { */ generateWadoHeader = (options: HeaderOptions): HeadersInterface => { const authorizationHeader = getAuthorizationHeader(); - if (options?.includeTransferSyntax!==false) { + if (options?.includeTransferSyntax !== false) { //Generate accept header depending on config params const formattedAcceptHeader = utils.generateAcceptHeader( dicomWebConfig.acceptHeader, @@ -175,7 +178,7 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { // which the server expects Accept: application/dicom+json will still include that in the // header. return { - ...authorizationHeader + ...authorizationHeader, }; } }; diff --git a/extensions/default/src/DicomWebDataSource/qido.js b/extensions/default/src/DicomWebDataSource/qido.js index ee13fa8c4b0..20ed59351d2 100644 --- a/extensions/default/src/DicomWebDataSource/qido.js +++ b/extensions/default/src/DicomWebDataSource/qido.js @@ -197,7 +197,7 @@ function mapParams(params, options = {}) { if (params.studyInstanceUid) { let studyUids = params.studyInstanceUid; studyUids = Array.isArray(studyUids) ? studyUids.join() : studyUids; - studyUids = studyUids.replace(/[^0-9.]+/g, '\\'); + // studyUids = studyUids.replace(/[^0-9\.]+/g, '\\'); parameters.StudyInstanceUID = studyUids; } diff --git a/extensions/default/src/ViewerLayout/index.tsx b/extensions/default/src/ViewerLayout/index.tsx index c1bbb42cfe1..2aa0442249f 100644 --- a/extensions/default/src/ViewerLayout/index.tsx +++ b/extensions/default/src/ViewerLayout/index.tsx @@ -34,6 +34,9 @@ function ViewerLayout({ const { panelService, hangingProtocolService, customizationService } = servicesManager.services; const [showLoadingIndicator, setShowLoadingIndicator] = useState(appConfig.showLoadingIndicator); + // Check if UI should be hidden based on config + const hideUI = !!appConfig.hideUI; + const hasPanels = useCallback( (side): boolean => !!panelService.getPanels(side).length, [panelService] @@ -151,21 +154,23 @@ function ViewerLayout({ return (
- + {!hideUI && ( + + )}
{showLoadingIndicator && } {/* LEFT SIDEPANELS */} - {hasLeftPanels ? ( + {hasLeftPanels && !hideUI ? ( <>
- {hasRightPanels ? ( + {hasRightPanels && !hideUI ? ( <> void; @@ -15,52 +15,37 @@ interface InputDialogDefaultProps { function InputDialogDefault({ hide, onSave, - placeholder = 'Enter value', + placeholder = 'Enter annotation text', defaultValue = '', submitOnEnter, }: InputDialogDefaultProps) { return ( - - - - - - Cancel - { - onSave(value); - hide(); - }} - > - Save - - - + placeholder={placeholder} + submitOnEnter={submitOnEnter} + /> ); } /** - * Shows an input dialog for entering text with customizable options + * Shows an inline annotation input for entering text * @param uiDialogService - Service for showing UI dialogs - * @param onSave - Callback function called when save button is clicked with entered value * @param defaultValue - Initial value to show in input field - * @param title - Title text to show in dialog header * @param placeholder - Placeholder text for input field * @param submitOnEnter - Whether to submit dialog when Enter key is pressed */ export async function callInputDialog({ uiDialogService, defaultValue = '', - title = 'Annotation', - placeholder = '', + placeholder = 'Enter annotation text', submitOnEnter = true, }: { uiDialogService: AppTypes.UIDialogService; defaultValue?: string; - title?: string; placeholder?: string; submitOnEnter?: boolean; }) { @@ -70,7 +55,7 @@ export async function callInputDialog({ uiDialogService.show({ id: dialogId, content: InputDialogDefault, - title: title, + unstyled: true, shouldCloseOnEsc: true, contentProps: { onSave: value => { diff --git a/modes/basic/src/index.tsx b/modes/basic/src/index.tsx index 9e61fbb6442..3e956c180d4 100644 --- a/modes/basic/src/index.tsx +++ b/modes/basic/src/index.tsx @@ -214,20 +214,23 @@ export const toolbarSections = { 'Zoom', 'Pan', 'TrackballRotate', - 'WindowLevel', + // 'WindowLevel', // Window level button disabled 'Capture', 'Layout', 'Crosshairs', 'MoreTools', ], - [TOOLBAR_SECTIONS.viewportActionMenu.topLeft]: ['orientationMenu', 'dataOverlayMenu'], + // Orientation and data overlay menus disabled + [TOOLBAR_SECTIONS.viewportActionMenu.topLeft]: [ + /* 'orientationMenu', 'dataOverlayMenu' */ + ], [TOOLBAR_SECTIONS.viewportActionMenu.bottomMiddle]: ['AdvancedRenderingControls'], AdvancedRenderingControls: [ - 'windowLevelMenuEmbedded', - 'voiManualControlMenu', + // 'windowLevelMenuEmbedded', // Window level menu disabled + // 'voiManualControlMenu', // VOI manual control disabled 'Colorbar', 'opacityMenu', 'thresholdMenu', @@ -239,7 +242,10 @@ export const toolbarSections = { 'navigationComponent', ], - [TOOLBAR_SECTIONS.viewportActionMenu.bottomLeft]: ['windowLevelMenu'], + // Window level menu disabled in bottom left + [TOOLBAR_SECTIONS.viewportActionMenu.bottomLeft]: [ + /* 'windowLevelMenu' */ + ], MeasurementTools: [ 'Length', diff --git a/modes/basic/src/initToolGroups.ts b/modes/basic/src/initToolGroups.ts index 6278f62619c..abd32513e11 100644 --- a/modes/basic/src/initToolGroups.ts +++ b/modes/basic/src/initToolGroups.ts @@ -19,13 +19,14 @@ function initDefaultToolGroup(extensionManager, toolGroupService, commandsManage const tools = { active: [ - { - toolName: toolNames.WindowLevel, - bindings: [{ mouseButton: Enums.MouseBindings.Primary }], - }, + // WindowLevel tool disabled - no mouse binding + // { + // toolName: toolNames.WindowLevel, + // bindings: [{ mouseButton: Enums.MouseBindings.Primary }], + // }, { toolName: toolNames.Pan, - bindings: [{ mouseButton: Enums.MouseBindings.Auxiliary }], + bindings: [{ mouseButton: Enums.MouseBindings.Primary }], }, { toolName: toolNames.Zoom, @@ -37,6 +38,7 @@ function initDefaultToolGroup(extensionManager, toolGroupService, commandsManage }, ], passive: [ + { toolName: toolNames.WindowLevel }, { toolName: toolNames.Length }, { toolName: toolNames.ArrowAnnotate, @@ -82,10 +84,7 @@ function initDefaultToolGroup(extensionManager, toolGroupService, commandsManage { toolName: toolNames.LivewireContour }, { toolName: toolNames.WindowLevelRegion }, ], - enabled: [ - { toolName: toolNames.ImageOverlayViewer }, - { toolName: toolNames.ReferenceLines }, - ], + enabled: [{ toolName: toolNames.ImageOverlayViewer }, { toolName: toolNames.ReferenceLines }], disabled: [ { toolName: toolNames.AdvancedMagnify, @@ -115,19 +114,20 @@ function initSRToolGroup(extensionManager, toolGroupService) { const { toolNames, Enums } = CS3DUtilityModule.exports; const tools = { active: [ - { - toolName: toolNames.WindowLevel, - bindings: [ - { - mouseButton: Enums.MouseBindings.Primary, - }, - ], - }, + // WindowLevel tool disabled - no mouse binding + // { + // toolName: toolNames.WindowLevel, + // bindings: [ + // { + // mouseButton: Enums.MouseBindings.Primary, + // }, + // ], + // }, { toolName: toolNames.Pan, bindings: [ { - mouseButton: Enums.MouseBindings.Auxiliary, + mouseButton: Enums.MouseBindings.Primary, }, ], }, @@ -179,13 +179,14 @@ function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) { const tools = { active: [ - { - toolName: toolNames.WindowLevel, - bindings: [{ mouseButton: Enums.MouseBindings.Primary }], - }, + // WindowLevel tool disabled - no mouse binding + // { + // toolName: toolNames.WindowLevel, + // bindings: [{ mouseButton: Enums.MouseBindings.Primary }], + // }, { toolName: toolNames.Pan, - bindings: [{ mouseButton: Enums.MouseBindings.Auxiliary }], + bindings: [{ mouseButton: Enums.MouseBindings.Primary }], }, { toolName: toolNames.Zoom, @@ -197,6 +198,7 @@ function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) { }, ], passive: [ + { toolName: toolNames.WindowLevel }, { toolName: toolNames.Length }, { toolName: toolNames.ArrowAnnotate, diff --git a/package.json b/package.json index c699ea88994..583670bfca9 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ }, "scripts": { "cm": "npx git-cz", - "build": "lerna run build:viewer --stream", + "build": "lerna run build:viewer --stream --skip-nx-cache", "build:dev": "lerna run build:dev --stream", "build:ci": "lerna run build:viewer:ci --stream", "build:qa": "lerna run build:viewer:qa --stream", diff --git a/platform/app/.webpack/writePluginImportsFile.js b/platform/app/.webpack/writePluginImportsFile.js index ba00dacf6d6..047ec12177f 100644 --- a/platform/app/.webpack/writePluginImportsFile.js +++ b/platform/app/.webpack/writePluginImportsFile.js @@ -10,8 +10,16 @@ const extractName = val => (typeof val === 'string' ? val : val.packageName); const publicURL = process.env.PUBLIC_URL || '/'; -function isAbsolutePath(path) { - return path.startsWith('http') || path.startsWith('/'); +function makeImportPath(path) { + if (path.startsWith('http')) { + return path; + } + + if ( path.startsWith('/')) { + path = path.slice(1); + } + + return `${publicURL}${path}` } function constructLines(input, categoryName) { @@ -77,7 +85,7 @@ function getRuntimeLoadModesExtensions(modules) { if (module.importPath) { dynamicLoad.push( ` if( module==="${packageName}") {`, - ` const imported = await window.browserImportFunction('${isAbsolutePath(module.importPath) ? '' : publicURL}${module.importPath}');`, + ` const imported = await window.browserImportFunction('${makeImportPath(module.importPath)}');`, ' return ' + (module.globalName ? `window["${module.globalName}"];` diff --git a/platform/app/public/config/butterfly.js b/platform/app/public/config/butterfly.js new file mode 100644 index 00000000000..9f7ed38fb88 --- /dev/null +++ b/platform/app/public/config/butterfly.js @@ -0,0 +1,158 @@ +/** @type {AppTypes.Config} */ + +window.config = { + name: 'config/butterfly.js', + routerBasename: '/static/ohif', + // whiteLabeling: {}, + extensions: [], + modes: [], + customizationService: {}, + showStudyList: false, + // some windows systems have issues with more than 3 web workers + maxNumberOfWebWorkers: 3, + // below flag is for performance reasons, but it might not work for all servers + showWarningMessageForCrossOrigin: true, + showCPUFallbackMessage: true, + showLoadingIndicator: true, + hideUI: true, // Hide toolbar and side panels by default for minimal UI + experimentalStudyBrowserSort: false, + strictZSpacingForVolumeViewport: true, + groupEnabledModesFirst: true, + allowMultiSelectExport: false, + maxNumRequests: { + interaction: 100, + thumbnail: 75, + // Prefetch number is dependent on the http protocol. For http 2 or + // above, the number of requests can be go a lot higher. + prefetch: 25, + }, + // filterQueryParam: false, + // Defines multi-monitor layouts + multimonitor: [ + { + id: 'split', + test: ({ multimonitor }) => multimonitor === 'split', + screens: [ + { + id: 'ohif0', + screen: null, + location: { + screen: 0, + width: 0.5, + height: 1, + left: 0, + top: 0, + }, + options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + { + id: 'ohif1', + screen: null, + location: { + width: 0.5, + height: 1, + left: 0.5, + top: 0, + }, + options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + ], + }, + + { + id: '2', + test: ({ multimonitor }) => multimonitor === '2', + screens: [ + { + id: 'ohif0', + screen: 0, + location: { + width: 1, + height: 1, + left: 0, + top: 0, + }, + options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + { + id: 'ohif1', + screen: 1, + location: { + width: 1, + height: 1, + left: 0, + top: 0, + }, + options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + ], + }, + ], + defaultDataSourceName: 'dicomweb', + /* Dynamic config allows user to pass "configUrl" query string this allows to load config without recompiling application. The regex will ensure valid configuration source */ + // dangerouslyUseDynamicConfig: { + // enabled: true, + // // regex will ensure valid configuration source and default is /.*/ which matches any character. To use this, setup your own regex to choose a specific source of configuration only. + // // Example 1, to allow numbers and letters in an absolute or sub-path only. + // // regex: /(0-9A-Za-z.]+)(\/[0-9A-Za-z.]+)*/ + // // Example 2, to restricts to either hosptial.com or othersite.com. + // // regex: /(https:\/\/hospital.com(\/[0-9A-Za-z.]+)*)|(https:\/\/othersite.com(\/[0-9A-Za-z.]+)*)/ + // regex: /.*/, + // }, + dataSources: [ + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'dicomweb', + configuration: { + friendlyName: 'BFLY DICOM WEB', + name: 'bfly', + wadoUriRoot: 'https://api.dev.cloud.butterflynetwork.com/api/v1/dicomweb', + qidoRoot: 'https://api.dev.cloud.butterflynetwork.com/api/v1/dicomweb', + wadoRoot: 'https://api.dev.cloud.butterflynetwork.com/api/v1/dicomweb', + qidoSupportsIncludeField: false, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: false, + supportsFuzzyMatching: true, + supportsWildcard: false, + staticWado: true, + // whether the data source should use retrieveBulkData to grab metadata, + // and in case of relative path, what would it be relative to, options + // are in the series level or study level (some servers like series some study) + bulkDataURI: { + enabled: true, + transform: url => { + console.log('original url', url); + + return url; + }, + }, + omitQuotationForMultipartRequest: true, + }, + }, + ], + httpErrorHandler: error => { + // This is 429 when rejected from the public idc sandbox too often. + console.warn(error.status); + + // Could use services manager here to bring up a dialog/modal if needed. + console.warn('test, navigate to https://ohif.org/'); + }, + // whiteLabeling: { + // createLogoComponentFn: function (React) { + // return React.createElement( + // 'a', + // { + // target: '_self', + // rel: 'noopener noreferrer', + // className: 'text-purple-600 line-through', + // href: '_X___IDC__LOGO__LINK___Y_', + // }, + // React.createElement('img', { + // src: './Logo.svg', + // className: 'w-14 h-14', + // }) + // ); + // }, + // }, +}; diff --git a/platform/app/public/config/default.js b/platform/app/public/config/default.js index f29a70acb4f..4788900aa45 100644 --- a/platform/app/public/config/default.js +++ b/platform/app/public/config/default.js @@ -2,7 +2,7 @@ window.config = { name: 'config/default.js', - routerBasename: null, + routerBasename: '/static/ohif', // For 'olympus-frontend' setup (return to 'null' once we start using butterfly.js) // whiteLabeling: {}, extensions: [], modes: [], @@ -14,6 +14,7 @@ window.config = { showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, showLoadingIndicator: true, + hideUI: true, // Hide toolbar and side panels by default for minimal UI experimentalStudyBrowserSort: false, strictZSpacingForVolumeViewport: true, groupEnabledModesFirst: true, diff --git a/platform/app/src/App.tsx b/platform/app/src/App.tsx index 446308d0092..a0f60eff42a 100644 --- a/platform/app/src/App.tsx +++ b/platform/app/src/App.tsx @@ -36,6 +36,7 @@ import appInit from './appInit.js'; import OpenIdConnectRoutes from './utils/OpenIdConnectRoutes'; import { ShepherdJourneyProvider } from 'react-shepherd'; import './App.css'; +import ButterflyProvider from './butterfly/ButterflyProvider'; let commandsManager: CommandsManager, extensionManager: ExtensionManager, @@ -173,8 +174,15 @@ function App({ basename={routerBasename} future={routerFutureFlags} > - {authRoutes} - {appRoutes} + + {authRoutes} + {appRoutes} + ); diff --git a/platform/app/src/butterfly/ButterflyProvider.tsx b/platform/app/src/butterfly/ButterflyProvider.tsx new file mode 100644 index 00000000000..927e0f0dd50 --- /dev/null +++ b/platform/app/src/butterfly/ButterflyProvider.tsx @@ -0,0 +1,439 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + CommandsManager, + ExtensionManager, + ServicesManager, + UserAuthenticationService, +} from '@ohif/core'; +import React from 'react'; +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +// TODO Remove when the BE is ready +const isInTesting = true; + +type MessageData = + | { + type: 'navigate'; + studyId: string; + } + | { + type: 'command'; + commandName: string; + options?: Record; + context?: string | string[]; + } + | { + type: 'setTool'; + toolName: string; + toolGroupId?: string; + } + | { + type: 'captureViewport'; + } + | { + type: 'setZoomLevel'; + level: '50%' | '70%' | '100%' | '150%' | '200%' | 'fit'; + } + | { + type: 'clearFrameAnnotations'; + } + | { + type: 'enableWindowLevel'; + }; + +type ButterflyProviderProps = { + userAuthenticationService: UserAuthenticationService; + commandsManager: CommandsManager; + extensionsManager: ExtensionManager; + servicesManager: ServicesManager; +}; + +export default function ButterflyProvider({ + children, + commandsManager, + extensionsManager, + servicesManager, + userAuthenticationService, +}: React.PropsWithChildren) { + const navigate = useNavigate(); + + useEffect(() => { + const onMessage = (ev: MessageEvent) => { + console.log('ButterflyProvider: Received message', ev.data); + + if (!(typeof ev.data === 'object')) { + return; + } + + if (!('type' in ev.data)) { + return; + } + + const { type } = ev.data; + + if (isInTesting && type === 'navigate' && 'studyUuid' in ev.data) { + const { studyUuid } = ev.data; + const url = `/viewer?StudyInstanceUIDs=${studyUuid}`; + navigate(url); + + // INFO Notify parent that navigation occurred + window.top.postMessage( + { + type: 'navigated', + studyId: studyUuid, + url: url, + }, + '*' + ); + } + + // INFO Handle navigation to a specific study + if (!isInTesting && type === 'navigate' && 'studyId' in ev.data) { + const { studyId } = ev.data; + const query = new URLSearchParams({ StudyInstanceUIDs: studyId }); + const dataSource = extensionsManager.getActiveDataSource()[0]; + dataSource.initialize({ params: {}, query }); + + const url = `/viewer?${query.toString()}`; + navigate(url); + + // INFO Notify parent that navigation occurred + window.top.postMessage( + { + type: 'navigated', + studyId: studyId, + url: url, + }, + '*' + ); + + if (!isInTesting) { + // INFO Add BF auth token for requests + userAuthenticationService.setServiceImplementation({ + getAuthorizationHeader() { + const bflyToken = JSON.parse(localStorage.getItem('bfly:token')).accessToken; + return { + Authorization: `JWT ${bflyToken}`, + 'olympus-organization': 'slug bni-slug', + }; + }, + getState() { + return {}; + }, + getUser() {}, + handleUnauthenticated() {}, + reset() {}, + set() {}, + setUser() {}, + }); + } + } + + if (type === 'command' && 'commandName' in ev.data) { + const { commandName, options = {}, context } = ev.data; + try { + const result = commandsManager.runCommand(commandName, options, context || 'CORNERSTONE'); + window.top.postMessage( + { + type: 'commandResult', + commandName, + success: true, + result, + }, + '*' + ); + } catch (error) { + console.error('ButterflyProvider: Command failed', commandName, error); + window.top.postMessage( + { + type: 'commandResult', + commandName, + success: false, + error: error.message, + }, + '*' + ); + } + } + + if (type === 'setTool' && 'toolName' in ev.data) { + const { toolName, toolGroupId } = ev.data; + try { + commandsManager.runCommand( + 'setToolActiveToolbar', + { + toolName, + toolGroupIds: toolGroupId ? [toolGroupId] : [], + }, + 'CORNERSTONE' + ); + + if (toolName === 'PlanarFreehandROI') { + window.top.postMessage( + { + type: 'freehandROIToolActivated', + toolName, + }, + '*' + ); + } + + window.top.postMessage( + { + type: 'toolChanged', + toolName, + success: true, + }, + '*' + ); + } catch (error) { + console.error('ButterflyProvider: Tool activation failed', toolName, error); + window.top.postMessage( + { + type: 'toolChanged', + toolName, + success: false, + error: error.message, + }, + '*' + ); + } + } + + if (type === 'captureViewport') { + try { + // INFO Add a small delay to ensure viewport and segmentation state are ready + setTimeout(() => { + commandsManager.runCommand('showDownloadViewportModal', {}, 'CORNERSTONE'); + window.top.postMessage( + { + type: 'captureViewportOpened', + success: true, + }, + '*' + ); + }, 100); + } catch (error) { + console.error('ButterflyProvider: Failed to open capture viewport modal', error); + window.top.postMessage( + { + type: 'captureViewportOpened', + success: false, + error: error.message, + }, + '*' + ); + } + } + + if (type === 'setZoomLevel' && 'level' in ev.data) { + const { level } = ev.data; + try { + const viewportGridService = servicesManager?.services?.viewportGridService; + const cornerstoneViewportService = servicesManager?.services?.cornerstoneViewportService; + + if (viewportGridService && cornerstoneViewportService) { + const activeViewportId = viewportGridService.getActiveViewportId(); + const viewport = cornerstoneViewportService.getCornerstoneViewport(activeViewportId); + + if (viewport) { + let zoomScale: number; + + switch (level) { + case '50%': + zoomScale = 0.5; + break; + case '70%': + zoomScale = 0.7; + break; + case '100%': + zoomScale = 1.0; + break; + case '150%': + zoomScale = 1.5; + break; + case '200%': + zoomScale = 2.0; + break; + case 'fit': + // INFO Reset to fit the viewport + viewport.resetCamera(); + viewport.render(); + window.top.postMessage( + { + type: 'zoomLevelSet', + level, + success: true, + }, + '*' + ); + return; + default: + throw new Error(`Invalid zoom level: ${level}`); + } + + const currentCamera = viewport.getCamera(); + const savedCamera = { ...currentCamera }; + viewport.resetCamera(); + const defaultCamera = viewport.getCamera(); + const defaultParallelScale = defaultCamera.parallelScale; + + const newParallelScale = defaultParallelScale / zoomScale; + + viewport.setCamera({ + ...savedCamera, + parallelScale: newParallelScale, + }); + viewport.render(); + + window.top.postMessage( + { + type: 'zoomLevelSet', + level, + success: true, + }, + '*' + ); + } else { + throw new Error('No active viewport found'); + } + } else { + throw new Error('Required services not available'); + } + } catch (error) { + console.error('ButterflyProvider: Failed to set zoom level', error); + window.top.postMessage( + { + type: 'zoomLevelSet', + level, + success: false, + error: error.message, + }, + '*' + ); + } + } + + // INFO Handle clearing annotations for current frame only + if (type === 'clearFrameAnnotations') { + try { + const viewportGridService = servicesManager?.services?.viewportGridService; + const cornerstoneViewportService = servicesManager?.services?.cornerstoneViewportService; + const measurementService = servicesManager?.services?.measurementService; + + if (viewportGridService && cornerstoneViewportService && measurementService) { + const activeViewportId = viewportGridService.getActiveViewportId(); + const viewport = cornerstoneViewportService.getCornerstoneViewport(activeViewportId); + + if (viewport) { + // INFO Using 'any' as viewport type doesn't include all Cornerstone methods + const currentImageIdIndex = (viewport as any).getCurrentImageIdIndex + ? (viewport as any).getCurrentImageIdIndex() + : 0; + const currentImageId = (viewport as any).getImageIds + ? (viewport as any).getImageIds()[currentImageIdIndex] + : null; + + if (currentImageId) { + const allMeasurements = measurementService.getMeasurements(); + + // INFO Filter and remove measurements that belong to the current frame + allMeasurements.forEach(measurement => { + // INFO Check if measurement belongs to the current image/frame + if ( + measurement.referenceImageId === currentImageId || + (measurement.metadata && + measurement.metadata.referencedImageId === currentImageId) || + (measurement.data && measurement.data.imageId === currentImageId) + ) { + try { + commandsManager.runCommand( + 'removeMeasurement', + { uid: measurement.uid }, + 'CORNERSTONE' + ); + } catch (e) { + console.warn('Failed to remove measurement:', measurement.uid, e); + } + } + }); + + // INFO Force viewport render to update display + viewport.render(); + + window.top.postMessage( + { + type: 'frameAnnotationsCleared', + frameIndex: currentImageIdIndex + 1, + success: true, + }, + '*' + ); + } else { + throw new Error('No current image found'); + } + } else { + throw new Error('No active viewport found'); + } + } else { + throw new Error('Required services not available'); + } + } catch (error) { + console.error('ButterflyProvider: Failed to clear frame annotations', error); + window.top.postMessage( + { + type: 'frameAnnotationsCleared', + success: false, + error: error.message, + }, + '*' + ); + } + } + + if (type === 'enableWindowLevel') { + try { + commandsManager.runCommand( + 'setToolActiveToolbar', + { + toolName: 'WindowLevel', + toolGroupIds: [], + }, + 'CORNERSTONE' + ); + + window.top.postMessage( + { + type: 'windowLevelEnabled', + success: true, + }, + '*' + ); + } catch (error) { + console.error('ButterflyProvider: Failed to enable window level tool', error); + window.top.postMessage( + { + type: 'windowLevelEnabled', + success: false, + error: error.message, + }, + '*' + ); + } + } + }; + + window.addEventListener('message', onMessage); + + // INFO Notify parent that OHIF is ready to receive messages + window.top.postMessage({ type: 'ready' }, '*'); + + return () => { + window.removeEventListener('message', onMessage); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return <>{children}; +} diff --git a/platform/app/src/components/ViewportGrid.tsx b/platform/app/src/components/ViewportGrid.tsx index 9c0d326a4ba..c1129085ca9 100644 --- a/platform/app/src/components/ViewportGrid.tsx +++ b/platform/app/src/components/ViewportGrid.tsx @@ -305,7 +305,7 @@ function ViewerViewportGrid(props: withAppTypes) { } return ( -
+
void; + onCancel: () => void; + defaultValue?: string; + placeholder?: string; + submitOnEnter?: boolean; + className?: string; + hide?: () => void; +} + +export function InlineAnnotationInput({ + onSave, + onCancel, + defaultValue = '', + placeholder = 'Enter annotation text', + submitOnEnter = true, + className, + hide, +}: InlineAnnotationInputProps) { + const [value, setValue] = useState(defaultValue); + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + if (defaultValue) { + inputRef.current.select(); + } + } + }, [defaultValue]); + + const handleSave = () => { + onSave(value); + hide?.(); + }; + + const handleCancel = () => { + onCancel(); + hide?.(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (submitOnEnter && e.key === 'Enter') { + e.preventDefault(); + handleSave(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancel(); + } + }; + + // TODO Try to use our components library + + return ( +
+ setValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="min-w-[200px] flex-1 rounded-md border border-[rgba(51,51,51,1)] bg-[rgba(51,51,51,1)] px-3 py-2 text-sm text-white placeholder-[rgba(128,128,128,1)] outline-none focus:border-[rgba(39,121,255,1)] focus:ring-1 focus:ring-[rgba(39,121,255,1)]" + /> + + +
+ ); +} + +export default InlineAnnotationInput; diff --git a/platform/ui-next/src/components/OHIFDialogs/index.ts b/platform/ui-next/src/components/OHIFDialogs/index.ts index f0657dc4c08..4d1d4d03bc5 100644 --- a/platform/ui-next/src/components/OHIFDialogs/index.ts +++ b/platform/ui-next/src/components/OHIFDialogs/index.ts @@ -1,2 +1,3 @@ export { InputDialog } from './InputDialog'; export { PresetDialog } from './PresetDialog'; +export { InlineAnnotationInput } from './InlineAnnotationInput'; diff --git a/platform/ui-next/src/components/Viewport/ViewportPane.tsx b/platform/ui-next/src/components/Viewport/ViewportPane.tsx index 2610ad2414c..4139d888346 100644 --- a/platform/ui-next/src/components/Viewport/ViewportPane.tsx +++ b/platform/ui-next/src/components/Viewport/ViewportPane.tsx @@ -66,13 +66,7 @@ function ViewportPane({
{children}
{/* Border overlay */} -
+
); } diff --git a/platform/ui-next/src/components/index.ts b/platform/ui-next/src/components/index.ts index 5fc7af79029..d2a8c4339cb 100644 --- a/platform/ui-next/src/components/index.ts +++ b/platform/ui-next/src/components/index.ts @@ -56,7 +56,7 @@ import { DisplaySetMessageListTooltip } from './DisplaySetMessageListTooltip'; import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from './Tooltip'; import { ToolboxUI } from './OHIFToolbox'; import Numeric from './Numeric'; -import { InputDialog, PresetDialog } from './OHIFDialogs'; +import { InputDialog, PresetDialog, InlineAnnotationInput } from './OHIFDialogs'; import { AboutModal, ImageModal, UserPreferencesModal } from './OHIFModals'; import Modal from './Modal/Modal'; import { FooterAction } from './FooterAction'; @@ -254,6 +254,7 @@ export { ToolButtonListDivider, InputDialog, PresetDialog, + InlineAnnotationInput, Modal, AboutModal, ImageModal, @@ -269,5 +270,5 @@ export { ProgressLoadingBar, ViewportDialog, CinePlayer, - LayoutSelector + LayoutSelector, };