diff --git a/extensions/dental/.webpack/webpack.dev.js b/extensions/dental/.webpack/webpack.dev.js new file mode 100644 index 00000000000..1b8e34cfd13 --- /dev/null +++ b/extensions/dental/.webpack/webpack.dev.js @@ -0,0 +1,12 @@ +const path = require('path'); +const webpackCommon = require('./../../../.webpack/webpack.base.js'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); + +const ENTRY = { + app: `${SRC_DIR}/index.ts`, +}; + +module.exports = (env, argv) => { + return webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY }); +}; diff --git a/extensions/dental/.webpack/webpack.prod.js b/extensions/dental/.webpack/webpack.prod.js new file mode 100644 index 00000000000..cf28933fb8e --- /dev/null +++ b/extensions/dental/.webpack/webpack.prod.js @@ -0,0 +1,54 @@ +const webpack = require('webpack'); +const { merge } = require('webpack-merge'); +const path = require('path'); +const webpackCommon = require('./../../../.webpack/webpack.base.js'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const pkg = require('./../package.json'); + +const outputName = `ohif-${pkg.name.split('/').pop()}`; + +const ROOT_DIR = path.join(__dirname, './..'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); + +const ENTRY = { + app: `${SRC_DIR}/index.ts`, +}; + +module.exports = (env, argv) => { + const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY }); + + return merge(commonConfig, { + stats: { + colors: true, + hash: true, + timings: true, + assets: true, + chunks: false, + chunkModules: false, + modules: false, + children: false, + warnings: true, + }, + optimization: { + minimize: true, + sideEffects: true, + }, + output: { + path: ROOT_DIR, + library: 'ohif-extension-dental', + libraryTarget: 'umd', + filename: pkg.main, + }, + externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/], + plugins: [ + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1, + }), + new MiniCssExtractPlugin({ + filename: `./dist/${outputName}.css`, + chunkFilename: `./dist/${outputName}.css`, + }), + ], + }); +}; diff --git a/extensions/dental/CHANGELOG.md b/extensions/dental/CHANGELOG.md new file mode 100644 index 00000000000..eacd3dd1e71 --- /dev/null +++ b/extensions/dental/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +## Unreleased +- Initial dental extension scaffold and layout/hanging protocol. diff --git a/extensions/dental/LICENSE b/extensions/dental/LICENSE new file mode 100644 index 00000000000..19e20dd35ca --- /dev/null +++ b/extensions/dental/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Open Health Imaging Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/dental/README.md b/extensions/dental/README.md new file mode 100644 index 00000000000..5b8b9497196 --- /dev/null +++ b/extensions/dental/README.md @@ -0,0 +1,9 @@ +# Dental Extension + +Adds a dental-specific layout template and hanging protocol for the OHIF Viewer. + +## Features +- Dental header with practice and patient context +- Tooth selector (FDI / Universal numbering) +- Dental 2x2 hanging protocol with bitewing placeholders +- Dental theme toggle diff --git a/extensions/dental/babel.config.js b/extensions/dental/babel.config.js new file mode 100644 index 00000000000..325ca2a8ee7 --- /dev/null +++ b/extensions/dental/babel.config.js @@ -0,0 +1 @@ +module.exports = require('../../babel.config.js'); diff --git a/extensions/dental/package.json b/extensions/dental/package.json new file mode 100644 index 00000000000..b9f6014e82a --- /dev/null +++ b/extensions/dental/package.json @@ -0,0 +1,52 @@ +{ + "name": "@ohif/extension-dental", + "version": "3.12.0-beta.127", + "description": "Dental UI customization and hanging protocol", + "author": "OHIF Contributors", + "license": "MIT", + "repository": "OHIF/Viewers", + "main": "dist/ohif-extension-dental.umd.js", + "module": "src/index.ts", + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1.16.0" + }, + "files": [ + "dist", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "keywords": [ + "ohif-extension" + ], + "scripts": { + "clean": "shx rm -rf dist", + "clean:deep": "yarn run clean && shx rm -rf node_modules", + "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo", + "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", + "build:package": "yarn run build", + "start": "yarn run dev", + "test:unit": "jest --watchAll", + "test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests" + }, + "peerDependencies": { + "prop-types": "15.8.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-i18next": "12.3.1", + "react-router": "6.30.3", + "react-router-dom": "6.30.3", + "webpack": "5.95.0", + "webpack-merge": "5.10.0" + }, + "dependencies": { + "@babel/runtime": "7.28.2", + "@ohif/core": "3.12.0-beta.127", + "@ohif/extension-default": "3.12.0-beta.127", + "@ohif/ui-next": "3.12.0-beta.127", + "classnames": "2.5.1" + } +} diff --git a/extensions/dental/src/ViewerLayout/DentalHeader.tsx b/extensions/dental/src/ViewerLayout/DentalHeader.tsx new file mode 100644 index 00000000000..6620f3333ec --- /dev/null +++ b/extensions/dental/src/ViewerLayout/DentalHeader.tsx @@ -0,0 +1,205 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +import { Button, Header, Icons, Switch, useModal } from '@ohif/ui-next'; +import { Types, useSystem } from '@ohif/core'; +import { Toolbar } from '@ohif/extension-default'; +import { preserveQueryParameters } from '@ohif/app'; + +import DentalToothSelector from './DentalToothSelector'; +import usePatientInfo from '../hooks/usePatientInfo'; + +import '../styles/dentalTheme.css'; + +const ToothIcon = ({ className = '' }) => ( + + + +); + +function DentalHeader({ appConfig }: withAppTypes<{ appConfig: AppTypes.Config }>) { + const { servicesManager, extensionManager, commandsManager } = useSystem(); + const { customizationService } = servicesManager.services; + + const navigate = useNavigate(); + const location = useLocation(); + const { t } = useTranslation(); + const { show } = useModal(); + + const { patientInfo, isMixedPatients } = usePatientInfo(); + + const [dentalThemeEnabled, setDentalThemeEnabled] = useState(() => { + if (typeof window === 'undefined') { + return true; + } + const stored = window.localStorage.getItem('ohif:dentalTheme'); + return stored ? stored === 'true' : true; + }); + + useEffect(() => { + const root = document.documentElement; + root.classList.toggle('dental-theme', dentalThemeEnabled); + window.localStorage.setItem('ohif:dentalTheme', dentalThemeEnabled.toString()); + + return () => { + root.classList.remove('dental-theme'); + }; + }, [dentalThemeEnabled]); + + const onClickReturnButton = () => { + const { pathname } = location; + const dataSourceIdx = pathname.indexOf('/', 1); + + const dataSourceName = pathname.substring(dataSourceIdx + 1); + const existingDataSource = extensionManager.getDataSources(dataSourceName); + + const searchQuery = new URLSearchParams(); + if (dataSourceIdx !== -1 && existingDataSource) { + searchQuery.append('datasources', pathname.substring(dataSourceIdx + 1)); + } + preserveQueryParameters(searchQuery); + + navigate({ + pathname: '/', + search: decodeURIComponent(searchQuery.toString()), + }); + }; + + const AboutModal = customizationService.getCustomization( + 'ohif.aboutModal' + ) as Types.MenuComponentCustomization; + const UserPreferencesModal = customizationService.getCustomization( + 'ohif.userPreferencesModal' + ) as Types.MenuComponentCustomization; + + const menuOptions = [ + { + title: AboutModal?.menuTitle ?? t('Header:About'), + icon: 'info', + onClick: () => + show({ + content: AboutModal, + title: AboutModal?.title ?? t('AboutModal:About OHIF Viewer'), + containerClassName: AboutModal?.containerClassName ?? 'max-w-md', + }), + }, + { + title: UserPreferencesModal.menuTitle ?? t('Header:Preferences'), + icon: 'settings', + onClick: () => + show({ + content: UserPreferencesModal, + title: UserPreferencesModal.title ?? t('UserPreferencesModal:User preferences'), + containerClassName: + UserPreferencesModal?.containerClassName ?? 'flex max-w-4xl p-6 flex-col', + }), + }, + ]; + + if (appConfig.oidc) { + menuOptions.push({ + title: t('Header:Logout'), + icon: 'power-off', + onClick: async () => { + navigate(`/logout?redirect_uri=${encodeURIComponent(window.location.href)}`); + }, + }); + } + + const dentalConfig = (appConfig as any)?.dental || {}; + const practiceName = + dentalConfig.practiceName || (appConfig.whiteLabeling as any)?.siteName || 'BrightSmile Dental'; + + const patientLabel = isMixedPatients + ? 'Multiple Patients' + : patientInfo.PatientName || 'Patient'; + + const patientMeta = [patientInfo.PatientID, patientInfo.PatientSex, patientInfo.PatientDOB] + .filter(Boolean) + .join(' / '); + + // Custom WhiteLabeling to replace OHIF logo with practice branding + const dentalWhiteLabeling = { + createLogoComponentFn: () => ( +
+
+ + {practiceName} +
+
+ ), + }; + + const undoRedo = ( +
+
+ + setDentalThemeEnabled(checked)} + className="scale-75" + /> +
+ + +
+ ); + + const patientInfoNode = ( +
+
+ {patientLabel} + {patientMeta || 'No metadata'} +
+
+ ); + + return ( +
+
+
+ +
+ +
+
+
+ ); +} + +export default DentalHeader; diff --git a/extensions/dental/src/ViewerLayout/DentalToothSelector.tsx b/extensions/dental/src/ViewerLayout/DentalToothSelector.tsx new file mode 100644 index 00000000000..cb8e10e25e2 --- /dev/null +++ b/extensions/dental/src/ViewerLayout/DentalToothSelector.tsx @@ -0,0 +1,81 @@ +import React, { useMemo, useState, useEffect } from 'react'; +import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ohif/ui-next'; +import classNames from 'classnames'; + +const FDI_TEETH = [ + '18','17','16','15','14','13','12','11', + '21','22','23','24','25','26','27','28', + '38','37','36','35','34','33','32','31', + '41','42','43','44','45','46','47','48', +]; + +const UNIVERSAL_TEETH = Array.from({ length: 32 }, (_, i) => `${i + 1}`); + +const SYSTEMS = { + FDI: { + label: 'FDI', + teeth: FDI_TEETH, + }, + Universal: { + label: 'Universal', + teeth: UNIVERSAL_TEETH, + }, +} as const; + +export type DentalToothSystem = keyof typeof SYSTEMS; + +function DentalToothSelector() { + const [system, setSystem] = useState('FDI'); + const [selectedTooth, setSelectedTooth] = useState(SYSTEMS.FDI.teeth[0]); + + const teeth = useMemo(() => SYSTEMS[system].teeth, [system]); + + useEffect(() => { + if (!teeth.includes(selectedTooth)) { + setSelectedTooth(teeth[0]); + } + }, [system]); + + return ( +
+
+ {(['FDI', 'Universal'] as DentalToothSystem[]).map(option => ( + + ))} +
+ +
+ ); +} + +export default DentalToothSelector; diff --git a/extensions/dental/src/ViewerLayout/DentalViewerLayout.tsx b/extensions/dental/src/ViewerLayout/DentalViewerLayout.tsx new file mode 100644 index 00000000000..7851f6f82a0 --- /dev/null +++ b/extensions/dental/src/ViewerLayout/DentalViewerLayout.tsx @@ -0,0 +1,234 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import PropTypes from 'prop-types'; + +import { InvestigationalUseDialog } from '@ohif/ui-next'; +import { HangingProtocolService, CommandsManager } from '@ohif/core'; +import { useAppConfig } from '@state'; +import DentalHeader from './DentalHeader'; +import SidePanelWithServices from './SidePanelWithServices'; +import { Onboarding, ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@ohif/ui-next'; +import useResizablePanels from './ResizablePanelsHook'; + +const resizableHandleClassName = 'mt-[1px] bg-black'; +type SidePanel = 'left' | 'right'; +type ViewportComponentConfig = { + namespace: string; + displaySetsToDisplay?: string[]; +}; + +function DentalViewerLayout({ + extensionManager, + servicesManager, + hotkeysManager, + commandsManager, + viewports, + ViewportGridComp, + leftPanelClosed = false, + rightPanelClosed = false, + leftPanelResizable = false, + rightPanelResizable = false, + leftPanelInitialExpandedWidth, + rightPanelInitialExpandedWidth, + leftPanelMinimumExpandedWidth, + rightPanelMinimumExpandedWidth, +}: withAppTypes): JSX.Element { + const [appConfig] = useAppConfig(); + + const { panelService, hangingProtocolService, customizationService } = servicesManager.services; + const [showLoadingIndicator, setShowLoadingIndicator] = useState(appConfig.showLoadingIndicator); + + const hasPanels = useCallback( + (side: SidePanel): boolean => !!panelService.getPanels(side).length, + [panelService] + ); + + const [hasRightPanels, setHasRightPanels] = useState(hasPanels('right')); + const [hasLeftPanels, setHasLeftPanels] = useState(hasPanels('left')); + const [leftPanelClosedState, setLeftPanelClosed] = useState(leftPanelClosed); + const [rightPanelClosedState, setRightPanelClosed] = useState(rightPanelClosed); + + const [ + leftPanelProps, + rightPanelProps, + resizablePanelGroupProps, + resizableLeftPanelProps, + resizableViewportGridPanelProps, + resizableRightPanelProps, + onHandleDragging, + ] = useResizablePanels( + leftPanelClosed, + setLeftPanelClosed, + rightPanelClosed, + setRightPanelClosed, + hasLeftPanels, + hasRightPanels, + leftPanelInitialExpandedWidth, + rightPanelInitialExpandedWidth, + leftPanelMinimumExpandedWidth, + rightPanelMinimumExpandedWidth + ); + + const handleMouseEnter = () => { + (document.activeElement as HTMLElement)?.blur(); + }; + + const LoadingIndicatorProgress = customizationService.getCustomization( + 'ui.loadingIndicatorProgress' + ) as React.ComponentType<{ className?: string }>; + + useEffect(() => { + document.body.classList.add('bg-black'); + document.body.classList.add('overflow-hidden'); + + return () => { + document.body.classList.remove('bg-black'); + document.body.classList.remove('overflow-hidden'); + }; + }, []); + + const getComponent = (id: string) => { + const entry = extensionManager.getModuleEntry(id); + + if (!entry || !entry.component) { + throw new Error( + `${id} is not valid for an extension module or no component found from extension ${id}. Please verify your configuration or ensure that the extension is properly registered. It's also possible that your mode is utilizing a module from an extension that hasn't been included in its dependencies (add the extension to the "extensionDependencies" array in your mode's index.js file). Check the reference string to the extension in your Mode configuration` + ); + } + + return { entry }; + }; + + useEffect(() => { + const { unsubscribe } = hangingProtocolService.subscribe( + HangingProtocolService.EVENTS.PROTOCOL_CHANGED, + () => { + setShowLoadingIndicator(false); + } + ); + + return () => { + unsubscribe(); + }; + }, [hangingProtocolService]); + + const getViewportComponentData = (viewportComponent: ViewportComponentConfig) => { + const { entry } = getComponent(viewportComponent.namespace); + + return { + component: entry.component, + isReferenceViewable: entry.isReferenceViewable, + displaySetsToDisplay: viewportComponent.displaySetsToDisplay, + }; + }; + + useEffect(() => { + const { unsubscribe } = panelService.subscribe( + panelService.EVENTS.PANELS_CHANGED, + ({ options }) => { + setHasLeftPanels(hasPanels('left')); + setHasRightPanels(hasPanels('right')); + if (options?.leftPanelClosed !== undefined) { + setLeftPanelClosed(options.leftPanelClosed); + } + if (options?.rightPanelClosed !== undefined) { + setRightPanelClosed(options.rightPanelClosed); + } + } + ); + + return () => { + unsubscribe(); + }; + }, [panelService, hasPanels]); + + const viewportComponents = (viewports ?? []).map(getViewportComponentData); + + return ( +
+ +
+ + {showLoadingIndicator && LoadingIndicatorProgress && ( + + )} + + {hasLeftPanels ? ( + <> + + + + + + ) : null} + +
+
+ +
+
+
+ {hasRightPanels ? ( + <> + + + + + + ) : null} +
+
+
+ + +
+ ); +} + +DentalViewerLayout.propTypes = { + extensionManager: PropTypes.shape({ + getModuleEntry: PropTypes.func.isRequired, + }).isRequired, + commandsManager: PropTypes.instanceOf(CommandsManager), + servicesManager: PropTypes.object.isRequired, + leftPanels: PropTypes.array, + rightPanels: PropTypes.array, + leftPanelClosed: PropTypes.bool.isRequired, + rightPanelClosed: PropTypes.bool.isRequired, + children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired, + viewports: PropTypes.array, +}; + +export default DentalViewerLayout; diff --git a/extensions/dental/src/ViewerLayout/ResizablePanelsHook.tsx b/extensions/dental/src/ViewerLayout/ResizablePanelsHook.tsx new file mode 100644 index 00000000000..dcbd8a0c5d7 --- /dev/null +++ b/extensions/dental/src/ViewerLayout/ResizablePanelsHook.tsx @@ -0,0 +1,290 @@ +import { useState, useCallback, useLayoutEffect, useRef } from 'react'; +import { getPanelElement, getPanelGroupElement } from 'react-resizable-panels'; +import { getPanelGroupDefinition } from './constants/panels'; + +const setMinMaxWidth = (elem, width?) => { + if (!elem) { + return; + } + + elem.style.minWidth = width === undefined ? '' : `${width}px`; + elem.style.maxWidth = elem.style.minWidth; +}; + +const useResizablePanels = ( + leftPanelClosed, + setLeftPanelClosed, + rightPanelClosed, + setRightPanelClosed, + hasLeftPanels, + hasRightPanels, + leftPanelInitialExpandedWidth, + rightPanelInitialExpandedWidth, + leftPanelMinimumExpandedWidth, + rightPanelMinimumExpandedWidth +) => { + const [panelGroupDefinition] = useState( + getPanelGroupDefinition({ + leftPanelInitialExpandedWidth, + rightPanelInitialExpandedWidth, + leftPanelMinimumExpandedWidth, + rightPanelMinimumExpandedWidth, + }) + ); + + const [leftPanelExpandedWidth, setLeftPanelExpandedWidth] = useState( + panelGroupDefinition.left.initialExpandedWidth + ); + const [rightPanelExpandedWidth, setRightPanelExpandedWidth] = useState( + panelGroupDefinition.right.initialExpandedWidth + ); + const [leftResizablePanelMinimumSize, setLeftResizablePanelMinimumSize] = useState(0); + const [rightResizablePanelMinimumSize, setRightResizablePanelMinimumSize] = useState(0); + const [leftResizablePanelCollapsedSize, setLeftResizePanelCollapsedSize] = useState(0); + const [rightResizePanelCollapsedSize, setRightResizePanelCollapsedSize] = useState(0); + + const resizablePanelGroupElemRef = useRef(null); + const resizableLeftPanelElemRef = useRef(null); + const resizableRightPanelElemRef = useRef(null); + const resizableLeftPanelAPIRef = useRef(null); + const resizableRightPanelAPIRef = useRef(null); + const isResizableHandleDraggingRef = useRef(false); + + const resizableHandlesWidth = useRef(null); + + useLayoutEffect(() => { + const panelGroupElem = getPanelGroupElement(panelGroupDefinition.groupId); + resizablePanelGroupElemRef.current = panelGroupElem; + + const leftPanelElem = getPanelElement(panelGroupDefinition.left.panelId); + resizableLeftPanelElemRef.current = leftPanelElem; + + const rightPanelElem = getPanelElement(panelGroupDefinition.right.panelId); + resizableRightPanelElemRef.current = rightPanelElem; + + const resizeHandles = document.querySelectorAll('[data-panel-resize-handle-id]'); + resizableHandlesWidth.current = 0; + resizeHandles.forEach(resizeHandle => { + resizableHandlesWidth.current += resizeHandle.offsetWidth; + }); + + if (!leftPanelClosed) { + const leftResizablePanelExpandedSize = getPercentageSize( + panelGroupDefinition.left.initialExpandedOffsetWidth + ); + resizableLeftPanelAPIRef?.current?.expand(leftResizablePanelExpandedSize); + setMinMaxWidth(leftPanelElem, panelGroupDefinition.left.initialExpandedOffsetWidth); + } + + if (!rightPanelClosed) { + const rightResizablePanelExpandedSize = getPercentageSize( + panelGroupDefinition.right.initialExpandedOffsetWidth + ); + resizableRightPanelAPIRef?.current?.expand(rightResizablePanelExpandedSize); + setMinMaxWidth(rightPanelElem, panelGroupDefinition.right.initialExpandedOffsetWidth); + } + }, []); + + useLayoutEffect(() => { + if (!resizableLeftPanelAPIRef.current?.isCollapsed()) { + const leftSize = getPercentageSize( + leftPanelExpandedWidth + panelGroupDefinition.shared.expandedInsideBorderSize + ); + resizableLeftPanelAPIRef.current?.resize(leftSize); + } + + if (!resizableRightPanelAPIRef?.current?.isCollapsed()) { + const rightSize = getPercentageSize( + rightPanelExpandedWidth + panelGroupDefinition.shared.expandedInsideBorderSize + ); + resizableRightPanelAPIRef?.current?.resize(rightSize); + } + + const observer = new ResizeObserver(() => { + const minimumLeftSize = getPercentageSize( + panelGroupDefinition.left.minimumExpandedOffsetWidth + ); + const minimumRightSize = getPercentageSize( + panelGroupDefinition.right.minimumExpandedOffsetWidth + ); + + setLeftResizablePanelMinimumSize(minimumLeftSize); + setRightResizablePanelMinimumSize(minimumRightSize); + setLeftResizePanelCollapsedSize( + getPercentageSize(panelGroupDefinition.left.collapsedOffsetWidth) + ); + setRightResizePanelCollapsedSize( + getPercentageSize(panelGroupDefinition.right.collapsedOffsetWidth) + ); + }); + + observer.observe(resizablePanelGroupElemRef.current); + + return () => { + observer.disconnect(); + }; + }, [ + leftPanelExpandedWidth, + rightPanelExpandedWidth, + leftResizablePanelMinimumSize, + rightResizablePanelMinimumSize, + hasLeftPanels, + hasRightPanels, + ]); + + const onHandleDragging = useCallback( + isStartDrag => { + if (isStartDrag) { + isResizableHandleDraggingRef.current = true; + + setMinMaxWidth(resizableLeftPanelElemRef.current); + setMinMaxWidth(resizableRightPanelElemRef.current); + } else { + isResizableHandleDraggingRef.current = false; + + if (resizableLeftPanelAPIRef?.current?.isExpanded()) { + setMinMaxWidth( + resizableLeftPanelElemRef.current, + leftPanelExpandedWidth + panelGroupDefinition.shared.expandedInsideBorderSize + ); + } + + if (resizableRightPanelAPIRef?.current?.isExpanded()) { + setMinMaxWidth( + resizableRightPanelElemRef.current, + rightPanelExpandedWidth + panelGroupDefinition.shared.expandedInsideBorderSize + ); + } + } + }, + [leftPanelExpandedWidth, rightPanelExpandedWidth] + ); + + const onLeftPanelClose = useCallback(() => { + setLeftPanelClosed(true); + setMinMaxWidth(resizableLeftPanelElemRef.current); + resizableLeftPanelAPIRef?.current?.collapse(); + }, [setLeftPanelClosed]); + + const onLeftPanelOpen = useCallback(() => { + resizableLeftPanelAPIRef?.current?.expand( + getPercentageSize(panelGroupDefinition.left.initialExpandedOffsetWidth) + ); + setLeftPanelClosed(false); + }, [setLeftPanelClosed]); + + const onRightPanelClose = useCallback(() => { + setRightPanelClosed(true); + setMinMaxWidth(resizableRightPanelElemRef.current); + resizableRightPanelAPIRef?.current?.collapse(); + }, [setRightPanelClosed]); + + const onRightPanelOpen = useCallback(() => { + resizableRightPanelAPIRef?.current?.expand( + getPercentageSize(panelGroupDefinition.right.initialExpandedOffsetWidth) + ); + setRightPanelClosed(false); + }, [setRightPanelClosed]); + + const onLayout = useCallback( + ({ left, right }) => { + if (isResizableHandleDraggingRef.current) { + const leftWidth = getPixelSize(left); + const rightWidth = getPixelSize(right); + + setLeftPanelExpandedWidth(leftWidth - panelGroupDefinition.shared.expandedInsideBorderSize); + setRightPanelExpandedWidth(rightWidth - panelGroupDefinition.shared.expandedInsideBorderSize); + } + }, + [panelGroupDefinition.shared.expandedInsideBorderSize] + ); + + const resizablePanelGroupProps = { + id: panelGroupDefinition.groupId, + autoSaveId: panelGroupDefinition.groupId, + direction: 'horizontal', + onLayout, + className: 'flex h-full w-full', + }; + + const resizableLeftPanelProps = { + id: panelGroupDefinition.left.panelId, + order: 1, + defaultSize: leftResizablePanelCollapsedSize, + minSize: leftResizablePanelMinimumSize, + maxSize: leftResizablePanelMinimumSize, + collapsedSize: leftResizablePanelCollapsedSize, + collapsible: true, + onCollapse: onLeftPanelClose, + onExpand: onLeftPanelOpen, + className: 'bg-black', + }; + + const resizableViewportGridPanelProps = { + id: 'viewerLayoutResizableViewportGrid', + order: 2, + defaultSize: 100 - leftResizablePanelCollapsedSize - rightResizePanelCollapsedSize, + minSize: 50, + }; + + const resizableRightPanelProps = { + id: panelGroupDefinition.right.panelId, + order: 3, + defaultSize: rightResizePanelCollapsedSize, + minSize: rightResizablePanelMinimumSize, + maxSize: rightResizablePanelMinimumSize, + collapsedSize: rightResizePanelCollapsedSize, + collapsible: true, + onCollapse: onRightPanelClose, + onExpand: onRightPanelOpen, + className: 'bg-black', + }; + + return [ + { + onOpen: onLeftPanelOpen, + onClose: onLeftPanelClose, + expandedWidth: leftPanelExpandedWidth, + collapsedWidth: panelGroupDefinition.shared.collapsedWidth, + expandedInsideBorderSize: panelGroupDefinition.shared.expandedInsideBorderSize, + collapsedInsideBorderSize: panelGroupDefinition.shared.collapsedInsideBorderSize, + collapsedOutsideBorderSize: panelGroupDefinition.shared.collapsedOutsideBorderSize, + }, + { + onOpen: onRightPanelOpen, + onClose: onRightPanelClose, + expandedWidth: rightPanelExpandedWidth, + collapsedWidth: panelGroupDefinition.shared.collapsedWidth, + expandedInsideBorderSize: panelGroupDefinition.shared.expandedInsideBorderSize, + collapsedInsideBorderSize: panelGroupDefinition.shared.collapsedInsideBorderSize, + collapsedOutsideBorderSize: panelGroupDefinition.shared.collapsedOutsideBorderSize, + }, + resizablePanelGroupProps, + resizableLeftPanelProps, + resizableViewportGridPanelProps, + resizableRightPanelProps, + onHandleDragging, + ]; +}; + +const getPixelSize = sizePercentage => { + const viewportGridElement = document.querySelector('.viewport-grid'); + if (!viewportGridElement) { + return 0; + } + + const { width } = viewportGridElement.getBoundingClientRect(); + return (sizePercentage / 100) * width; +}; + +const getPercentageSize = sizePixels => { + const viewportGridElement = document.querySelector('.viewport-grid'); + if (!viewportGridElement) { + return 0; + } + + const { width } = viewportGridElement.getBoundingClientRect(); + return (sizePixels / width) * 100; +}; + +export default useResizablePanels; diff --git a/extensions/dental/src/ViewerLayout/SidePanelWithServices.tsx b/extensions/dental/src/ViewerLayout/SidePanelWithServices.tsx new file mode 100644 index 00000000000..4acd918fa47 --- /dev/null +++ b/extensions/dental/src/ViewerLayout/SidePanelWithServices.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { SidePanel } from '@ohif/ui-next'; +import { Types } from '@ohif/core'; + +export type SidePanelWithServicesProps = { + servicesManager: AppTypes.ServicesManager; + side: 'left' | 'right'; + className?: string; + activeTabIndex: number; + tabs?: any; + expandedWidth?: number; + onClose: () => void; + onOpen: () => void; + isExpanded: boolean; + collapsedWidth?: number; + expandedInsideBorderSize?: number; + collapsedInsideBorderSize?: number; + collapsedOutsideBorderSize?: number; +}; + +const SidePanelWithServices = ({ + servicesManager, + side, + activeTabIndex: activeTabIndexProp, + isExpanded, + tabs: tabsProp, + onOpen, + onClose, + ...props +}: SidePanelWithServicesProps) => { + const { panelService, toolbarService, viewportGridService } = servicesManager.services; + + const [sidePanelExpanded, setSidePanelExpanded] = useState(isExpanded); + const [activeTabIndex, setActiveTabIndex] = useState(activeTabIndexProp ?? 0); + const [closedManually, setClosedManually] = useState(false); + const [tabs, setTabs] = useState(tabsProp ?? panelService.getPanels(side as any)); + + const handleActiveTabIndexChange = useCallback( + ({ activeTabIndex }) => { + const { activeViewportId: viewportId } = viewportGridService.getState(); + toolbarService.refreshToolbarState({ viewportId }); + + setActiveTabIndex(activeTabIndex); + }, + [toolbarService, viewportGridService] + ); + + const handleOpen = useCallback(() => { + setSidePanelExpanded(true); + onOpen?.(); + }, [onOpen]); + + const handleClose = useCallback(() => { + setSidePanelExpanded(false); + setClosedManually(true); + onClose?.(); + }, [onClose]); + + useEffect(() => { + setSidePanelExpanded(isExpanded); + }, [isExpanded]); + + useEffect(() => { + setActiveTabIndex(activeTabIndexProp ?? 0); + }, [activeTabIndexProp]); + + useEffect(() => { + const { unsubscribe } = panelService.subscribe( + panelService.EVENTS.PANELS_CHANGED, + (panelChangedEvent: any) => { + if (panelChangedEvent.position !== side) { + return; + } + + setTabs(panelService.getPanels(side as any)); + } + ); + + return () => { + unsubscribe(); + }; + }, [panelService, side]); + + useEffect(() => { + const activatePanelSubscription = panelService.subscribe( + panelService.EVENTS.ACTIVATE_PANEL, + (activatePanelEvent: Types.ActivatePanelEvent) => { + if (sidePanelExpanded || activatePanelEvent.forceActive) { + const tabIndex = tabs.findIndex(tab => tab.id === activatePanelEvent.panelId); + if (tabIndex !== -1) { + if (!closedManually) { + setSidePanelExpanded(true); + } + setActiveTabIndex(tabIndex); + } + } + } + ); + + return () => { + activatePanelSubscription.unsubscribe(); + }; + }, [tabs, sidePanelExpanded, panelService, closedManually]); + + return ( + + ); +}; + +export default SidePanelWithServices; diff --git a/extensions/dental/src/ViewerLayout/constants/panels.ts b/extensions/dental/src/ViewerLayout/constants/panels.ts new file mode 100644 index 00000000000..eaad18b6d31 --- /dev/null +++ b/extensions/dental/src/ViewerLayout/constants/panels.ts @@ -0,0 +1,37 @@ +const expandedInsideBorderSize = 0; +const collapsedInsideBorderSize = 4; +const collapsedOutsideBorderSize = 4; +const collapsedWidth = 25; + +const getPanelGroupDefinition = ({ + leftPanelInitialExpandedWidth = 282, + rightPanelInitialExpandedWidth = 280, + leftPanelMinimumExpandedWidth = 145, + rightPanelMinimumExpandedWidth = 280, +}) => { + return { + groupId: 'viewerLayoutResizablePanelGroup', + shared: { + expandedInsideBorderSize, + collapsedInsideBorderSize, + collapsedOutsideBorderSize, + collapsedWidth, + }, + left: { + panelId: 'viewerLayoutResizableLeftPanel', + initialExpandedWidth: leftPanelInitialExpandedWidth, + minimumExpandedOffsetWidth: leftPanelMinimumExpandedWidth + expandedInsideBorderSize, + initialExpandedOffsetWidth: leftPanelInitialExpandedWidth + expandedInsideBorderSize, + collapsedOffsetWidth: collapsedWidth + collapsedInsideBorderSize + collapsedOutsideBorderSize, + }, + right: { + panelId: 'viewerLayoutResizableRightPanel', + initialExpandedWidth: rightPanelInitialExpandedWidth, + minimumExpandedOffsetWidth: rightPanelMinimumExpandedWidth + expandedInsideBorderSize, + initialExpandedOffsetWidth: rightPanelInitialExpandedWidth + expandedInsideBorderSize, + collapsedOffsetWidth: collapsedWidth + collapsedInsideBorderSize + collapsedOutsideBorderSize, + }, + }; +}; + +export { getPanelGroupDefinition }; diff --git a/extensions/dental/src/custom-attribute/sameAs.ts b/extensions/dental/src/custom-attribute/sameAs.ts new file mode 100644 index 00000000000..e7f5ba03d6b --- /dev/null +++ b/extensions/dental/src/custom-attribute/sameAs.ts @@ -0,0 +1,27 @@ +/** + * Compare a display set attribute against a previously matched display set attribute. + * Expects matching rule properties: + * - sameAttribute: attribute name to compare (e.g. 'Modality') + * - sameDisplaySetId: display set selector id to compare against + */ +export default function (displaySet, options) { + const { sameAttribute, sameDisplaySetId } = this; + + if (!sameAttribute || !sameDisplaySetId) { + return false; + } + + const { displaySetMatchDetails, displaySets } = options; + const match = displaySetMatchDetails.get(sameDisplaySetId); + if (!match) { + return false; + } + + const { displaySetInstanceUID } = match; + const altDisplaySet = displaySets.find(it => it.displaySetInstanceUID === displaySetInstanceUID); + if (!altDisplaySet) { + return false; + } + + return altDisplaySet[sameAttribute] === displaySet[sameAttribute]; +} diff --git a/extensions/dental/src/getHangingProtocolModule.ts b/extensions/dental/src/getHangingProtocolModule.ts new file mode 100644 index 00000000000..330ecec3009 --- /dev/null +++ b/extensions/dental/src/getHangingProtocolModule.ts @@ -0,0 +1,10 @@ +import hpDental from './hangingprotocols/hpDental'; + +export default function getHangingProtocolModule() { + return [ + { + name: hpDental.id, + protocol: hpDental, + }, + ]; +} diff --git a/extensions/dental/src/getLayoutTemplateModule.ts b/extensions/dental/src/getLayoutTemplateModule.ts new file mode 100644 index 00000000000..9e6ec68e31e --- /dev/null +++ b/extensions/dental/src/getLayoutTemplateModule.ts @@ -0,0 +1,21 @@ +import DentalViewerLayout from './ViewerLayout/DentalViewerLayout'; + +export default function ({ servicesManager, extensionManager, commandsManager, hotkeysManager }) { + function DentalViewerLayoutWithServices(props) { + return DentalViewerLayout({ + servicesManager, + extensionManager, + commandsManager, + hotkeysManager, + ...props, + }); + } + + return [ + { + name: 'dentalViewerLayout', + id: 'dentalViewerLayout', + component: DentalViewerLayoutWithServices, + }, + ]; +} diff --git a/extensions/dental/src/getPanelModule.tsx b/extensions/dental/src/getPanelModule.tsx new file mode 100644 index 00000000000..2bd012554c0 --- /dev/null +++ b/extensions/dental/src/getPanelModule.tsx @@ -0,0 +1,23 @@ +import { Types } from '@ohif/core'; +import { PanelDentalMeasurements } from './panels'; +import i18n from 'i18next'; +import React from 'react'; + +function getPanelModule({ commandsManager, extensionManager, servicesManager }): Types.Panel[] { + return [ + { + name: 'dentalMeasurements', + iconName: 'tab-linear', + iconLabel: 'Dental Measurements', + label: i18n.t('SidePanel:Dental Measurements'), + component: props => ( + + ), + }, + ]; +} + +export default getPanelModule; diff --git a/extensions/dental/src/hangingprotocols/hpDental.ts b/extensions/dental/src/hangingprotocols/hpDental.ts new file mode 100644 index 00000000000..c448cd34146 --- /dev/null +++ b/extensions/dental/src/hangingprotocols/hpDental.ts @@ -0,0 +1,176 @@ +import { Types } from '@ohif/core'; + +const currentDisplaySetSelector = { + studyMatchingRules: [ + { + attribute: 'studyInstanceUIDsIndex', + from: 'options', + required: true, + constraint: { + equals: { value: 0 }, + }, + }, + ], + seriesMatchingRules: [ + { + attribute: 'numImageFrames', + constraint: { + greaterThan: { value: 0 }, + }, + }, + { + attribute: 'isDisplaySetFromUrl', + weight: 20, + constraint: { + equals: true, + }, + }, + ], +}; + +const priorDisplaySetSelector = { + studyMatchingRules: [ + { + attribute: 'studyInstanceUIDsIndex', + from: 'options', + required: true, + constraint: { + equals: { value: 1 }, + }, + }, + ], + seriesMatchingRules: [ + { + attribute: 'numImageFrames', + constraint: { + greaterThan: { value: 0 }, + }, + }, + { + attribute: 'sameAs', + sameAttribute: 'Modality', + sameDisplaySetId: 'currentDisplaySetId', + weight: 10, + }, + ], +}; + +const bitewingDisplaySetSelector = { + seriesMatchingRules: [ + { + attribute: 'SeriesDescription', + required: true, + constraint: { + containsI: 'bitewing', + }, + }, + ], +}; + +const defaultViewportOptions = { + viewportType: 'stack', + toolGroupId: 'default', + allowUnmatchedView: true, +}; + +const currentViewport = { + viewportOptions: defaultViewportOptions, + displaySets: [ + { + id: 'currentDisplaySetId', + }, + ], +}; + +const priorViewport = { + viewportOptions: { + ...defaultViewportOptions, + allowUnmatchedView: false, + customViewportProps: { + placeholder: 'Prior exam', + }, + }, + displaySets: [ + { + id: 'priorDisplaySetId', + }, + ], +}; + +const bitewingLeftViewport = { + viewportOptions: { + ...defaultViewportOptions, + allowUnmatchedView: false, + customViewportProps: { + placeholder: 'Bitewing (Left)', + }, + }, + displaySets: [ + { + id: 'bitewingDisplaySetId', + matchedDisplaySetsIndex: 0, + }, + ], +}; + +const bitewingRightViewport = { + viewportOptions: { + ...defaultViewportOptions, + allowUnmatchedView: false, + customViewportProps: { + placeholder: 'Bitewing (Right)', + }, + }, + displaySets: [ + { + id: 'bitewingDisplaySetId', + matchedDisplaySetsIndex: 1, + }, + ], +}; + +const hpDental: Types.HangingProtocol.Protocol = { + id: '@ohif/hpDental', + description: 'Dental 2x2 protocol with current, prior, and bitewing placeholders', + name: 'Dental 2x2', + numberOfPriorsReferenced: 1, + protocolMatchingRules: [], + toolGroupIds: ['default'], + displaySetSelectors: { + currentDisplaySetId: currentDisplaySetSelector, + priorDisplaySetId: priorDisplaySetSelector, + bitewingDisplaySetId: bitewingDisplaySetSelector, + }, + defaultViewport: { + viewportOptions: { + ...defaultViewportOptions, + allowUnmatchedView: false, + }, + displaySets: [ + { + id: 'currentDisplaySetId', + matchedDisplaySetsIndex: -1, + }, + ], + }, + stages: [ + { + name: 'Dental 2x2', + stageActivation: { + enabled: { + minViewportsMatched: 1, + }, + }, + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 2, + columns: 2, + }, + }, + viewports: [currentViewport, priorViewport, bitewingLeftViewport, bitewingRightViewport], + }, + ], +}; + +export default hpDental; diff --git a/extensions/dental/src/hooks/usePatientInfo.tsx b/extensions/dental/src/hooks/usePatientInfo.tsx new file mode 100644 index 00000000000..bbb7084c915 --- /dev/null +++ b/extensions/dental/src/hooks/usePatientInfo.tsx @@ -0,0 +1,67 @@ +import { useState, useEffect } from 'react'; +import { utils, useSystem } from '@ohif/core'; + +const { formatPN, formatDate } = utils; + +function usePatientInfo() { + const { servicesManager } = useSystem(); + const { displaySetService } = servicesManager.services; + + const [patientInfo, setPatientInfo] = useState({ + PatientName: '', + PatientID: '', + PatientSex: '', + PatientDOB: '', + }); + const [isMixedPatients, setIsMixedPatients] = useState(false); + + const checkMixedPatients = (PatientID: string) => { + const displaySets = displaySetService.getActiveDisplaySets(); + let mixed = false; + displaySets.forEach(displaySet => { + const instance = displaySet?.instances?.[0] || displaySet?.instance; + if (!instance) { + return; + } + if (instance.PatientID !== PatientID) { + mixed = true; + } + }); + setIsMixedPatients(mixed); + }; + + const updatePatientInfo = (event: unknown) => { + if (!event || typeof event !== 'object' || !('displaySetsAdded' in event)) { + return; + } + const { displaySetsAdded } = event as { displaySetsAdded: any[] }; + if (!Array.isArray(displaySetsAdded) || !displaySetsAdded.length) { + return; + } + const displaySet = displaySetsAdded[0]; + const instance = displaySet?.instances?.[0] || displaySet?.instance; + if (!instance) { + return; + } + + setPatientInfo({ + PatientID: instance.PatientID || null, + PatientName: instance.PatientName ? formatPN(instance.PatientName) : null, + PatientSex: instance.PatientSex || null, + PatientDOB: formatDate(instance.PatientBirthDate) || null, + }); + checkMixedPatients(instance.PatientID || null); + }; + + useEffect(() => { + const subscription = displaySetService.subscribe( + displaySetService.EVENTS.DISPLAY_SETS_ADDED, + props => updatePatientInfo(props) + ); + return () => subscription.unsubscribe(); + }, []); + + return { patientInfo, isMixedPatients }; +} + +export default usePatientInfo; diff --git a/extensions/dental/src/id.js b/extensions/dental/src/id.js new file mode 100644 index 00000000000..ebe5acd98ae --- /dev/null +++ b/extensions/dental/src/id.js @@ -0,0 +1,5 @@ +import packageJson from '../package.json'; + +const id = packageJson.name; + +export { id }; diff --git a/extensions/dental/src/index.ts b/extensions/dental/src/index.ts new file mode 100644 index 00000000000..313d737f2bc --- /dev/null +++ b/extensions/dental/src/index.ts @@ -0,0 +1,24 @@ +import { Types } from '@ohif/core'; + +import { id } from './id'; +import getLayoutTemplateModule from './getLayoutTemplateModule'; +import getHangingProtocolModule from './getHangingProtocolModule'; +import getPanelModule from './getPanelModule'; +import sameAs from './custom-attribute/sameAs'; + +const dentalExtension = { + id, + preRegistration: ({ servicesManager }: Types.Extensions.ExtensionParams) => { + const { hangingProtocolService } = servicesManager.services; + hangingProtocolService.addCustomAttribute( + 'sameAs', + 'Match attribute value against a previously matched display set', + sameAs + ); + }, + getLayoutTemplateModule, + getHangingProtocolModule, + getPanelModule, +}; + +export default dentalExtension; diff --git a/extensions/dental/src/panels/PanelDentalMeasurements.tsx b/extensions/dental/src/panels/PanelDentalMeasurements.tsx new file mode 100644 index 00000000000..47b335e4fe9 --- /dev/null +++ b/extensions/dental/src/panels/PanelDentalMeasurements.tsx @@ -0,0 +1,341 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useSystem } from '@ohif/core'; +import { Button, ScrollArea, Input, Icons } from '@ohif/ui-next'; +import classNames from 'classnames'; + +// Dental measurement presets with their tool configurations +const DENTAL_PRESETS = [ + { + id: 'pa-length', + label: 'PA length', + description: 'Periapical length measurement', + tool: 'Length', + unit: 'mm', + icon: 'tool-length', + }, + { + id: 'canal-angle', + label: 'Canal angle', + description: 'Root canal angle measurement', + tool: 'Angle', + unit: '°', + icon: 'tool-angle', + }, + { + id: 'crown-width', + label: 'Crown width', + description: 'Crown width measurement', + tool: 'Length', + unit: 'mm', + icon: 'tool-length', + }, + { + id: 'root-length', + label: 'Root length', + description: 'Root length measurement', + tool: 'Length', + unit: 'mm', + icon: 'tool-length', + }, +]; + +interface Measurement { + uid: string; + label: string; + displayText: string[]; + type: string; + unit: string; + toolName: string; + SOPInstanceUID: string; + referenceStudyUID: string; + [key: string]: unknown; +} + +interface SortConfig { + key: 'label' | 'type' | 'value'; + direction: 'asc' | 'desc'; +} + +function PanelDentalMeasurements() { + const { servicesManager, commandsManager } = useSystem(); + const { measurementService } = servicesManager.services; + + const [measurements, setMeasurements] = useState([]); + const [activePreset, setActivePreset] = useState(null); + const [filterText, setFilterText] = useState(''); + const [sortConfig, setSortConfig] = useState({ key: 'label', direction: 'asc' }); + + // Store pending label for next measurement + const pendingLabelRef = React.useRef(null); + + const refreshMeasurements = useCallback(() => { + const allMeasurements = measurementService.getMeasurements() || []; + setMeasurements(allMeasurements); + }, [measurementService]); + + useEffect(() => { + refreshMeasurements(); + + // Subscribe to measurement events + const subscriptions = [ + measurementService.subscribe( + measurementService.EVENTS.MEASUREMENT_ADDED, + ({ measurement }) => { + // Apply pending label if set + if (pendingLabelRef.current && measurement) { + measurementService.update(measurement.uid, { + ...measurement, + label: pendingLabelRef.current, + }); + pendingLabelRef.current = null; + setActivePreset(null); + } + refreshMeasurements(); + } + ), + measurementService.subscribe( + measurementService.EVENTS.MEASUREMENT_UPDATED, + refreshMeasurements + ), + measurementService.subscribe( + measurementService.EVENTS.MEASUREMENT_REMOVED, + refreshMeasurements + ), + measurementService.subscribe( + measurementService.EVENTS.MEASUREMENTS_CLEARED, + refreshMeasurements + ), + ]; + + return () => { + subscriptions.forEach(sub => sub.unsubscribe()); + }; + }, [measurementService, refreshMeasurements]); + + const handlePresetClick = (preset: typeof DENTAL_PRESETS[0]) => { + // Set the pending label for the next measurement + pendingLabelRef.current = preset.label; + setActivePreset(preset.id); + + // Activate the corresponding tool + commandsManager.runCommand('setToolActiveToolbar', { + toolName: preset.tool, + toolGroupIds: ['default', 'mpr', 'SRToolGroup'], + }); + }; + + const handleDeleteMeasurement = (uid: string) => { + measurementService.remove(uid); + }; + + const handleJumpToMeasurement = (measurement: Measurement) => { + commandsManager.runCommand('jumpToMeasurement', { + uid: measurement.uid, + }); + }; + + const handleExportJSON = () => { + const exportData = measurements.map(m => ({ + uid: m.uid, + label: m.label || 'Unlabeled', + type: m.type || m.toolName, + displayText: m.displayText, + referenceStudyUID: m.referenceStudyUID, + SOPInstanceUID: m.SOPInstanceUID, + })); + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `dental-measurements-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + // Filter and sort measurements + const filteredMeasurements = measurements + .filter(m => { + if (!filterText) return true; + const searchLower = filterText.toLowerCase(); + const label = (m.label || '').toLowerCase(); + const type = (m.type || m.toolName || '').toLowerCase(); + return label.includes(searchLower) || type.includes(searchLower); + }) + .sort((a, b) => { + const { key, direction } = sortConfig; + let aVal: string | number = ''; + let bVal: string | number = ''; + + if (key === 'label') { + aVal = a.label || ''; + bVal = b.label || ''; + } else if (key === 'type') { + aVal = a.type || a.toolName || ''; + bVal = b.type || b.toolName || ''; + } else if (key === 'value') { + // Extract numeric value from displayText + aVal = a.displayText?.[0] || ''; + bVal = b.displayText?.[0] || ''; + } + + if (direction === 'asc') { + return aVal < bVal ? -1 : aVal > bVal ? 1 : 0; + } + return aVal > bVal ? -1 : aVal < bVal ? 1 : 0; + }); + + const toggleSort = (key: SortConfig['key']) => { + setSortConfig(prev => ({ + key, + direction: prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc', + })); + }; + + const getMeasurementValue = (m: Measurement): string => { + if (m.displayText && m.displayText.length > 0) { + return m.displayText.join(' '); + } + return 'N/A'; + }; + + return ( +
+ {/* Preset Buttons */} +
+

Measurement Presets

+
+ {DENTAL_PRESETS.map(preset => ( + + ))} +
+ {activePreset && ( +

+ Click on the image to place measurement +

+ )} +
+ + {/* Filter and Sort Controls */} +
+
+ setFilterText(e.target.value)} + className="h-8 flex-1 text-sm" + /> + +
+ + {/* Sort buttons */} +
+ + + +
+
+ + {/* Measurements List */} + +
+ {filteredMeasurements.length === 0 ? ( +
+ {measurements.length === 0 + ? 'No measurements yet. Use presets above to start measuring.' + : 'No measurements match your filter.'} +
+ ) : ( +
+ {filteredMeasurements.map(measurement => ( +
handleJumpToMeasurement(measurement)} + > +
+
+ + {measurement.label || 'Unlabeled'} + + + ({measurement.type || measurement.toolName}) + +
+
+ {getMeasurementValue(measurement)} +
+
+ +
+ ))} +
+ )} +
+
+ + {/* Footer with count */} +
+ {filteredMeasurements.length} of {measurements.length} measurement(s) +
+
+ ); +} + +export default PanelDentalMeasurements; diff --git a/extensions/dental/src/panels/index.ts b/extensions/dental/src/panels/index.ts new file mode 100644 index 00000000000..c1de2579c61 --- /dev/null +++ b/extensions/dental/src/panels/index.ts @@ -0,0 +1,3 @@ +import PanelDentalMeasurements from './PanelDentalMeasurements'; + +export { PanelDentalMeasurements }; diff --git a/extensions/dental/src/styles/dentalTheme.css b/extensions/dental/src/styles/dentalTheme.css new file mode 100644 index 00000000000..3d395dc3b64 --- /dev/null +++ b/extensions/dental/src/styles/dentalTheme.css @@ -0,0 +1,48 @@ +.dental-theme { + --highlight: 188 86% 64%; + --neutral: 204 18% 60%; + --neutral-light: 197 55% 78%; + --neutral-dark: 210 20% 18%; + --background: 210 35% 7%; + --foreground: 0 0% 98%; + --card: 210 36% 12%; + --card-foreground: 0 0% 98%; + --popover: 204 42% 14%; + --popover-foreground: 0 0% 98%; + --primary: 188 88% 56%; + --primary-foreground: 210 20% 10%; + --secondary: 168 55% 42%; + --secondary-foreground: 196 60% 85%; + --muted: 210 30% 12%; + --muted-foreground: 200 35% 70%; + --accent: 176 65% 26%; + --accent-foreground: 0 0% 98%; + --destructive: 0 70% 35%; + --destructive-foreground: 0 0% 98%; + --border: 206 26% 24%; + --input: 206 26% 24%; + --ring: 188 88% 56%; + + font-family: 'Inter', 'Segoe UI', sans-serif; + letter-spacing: 0.01em; +} + +.dental-theme .dental-header { + background: linear-gradient(135deg, rgba(10, 40, 52, 0.9), rgba(7, 20, 28, 0.9)); + border-bottom: 1px solid rgba(110, 200, 220, 0.2); +} + +.dental-theme .dental-badge { + background: rgba(56, 189, 248, 0.15); + border: 1px solid rgba(56, 189, 248, 0.35); +} + +.dental-theme .dental-tooth-button { + border-color: rgba(148, 208, 219, 0.4); +} + +.dental-theme .dental-tooth-button.is-active { + background: rgba(56, 189, 248, 0.2); + border-color: rgba(56, 189, 248, 0.8); + color: rgb(230, 250, 255); +} diff --git a/modes/dental/.webpack/webpack.dev.js b/modes/dental/.webpack/webpack.dev.js new file mode 100644 index 00000000000..1b8e34cfd13 --- /dev/null +++ b/modes/dental/.webpack/webpack.dev.js @@ -0,0 +1,12 @@ +const path = require('path'); +const webpackCommon = require('./../../../.webpack/webpack.base.js'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); + +const ENTRY = { + app: `${SRC_DIR}/index.ts`, +}; + +module.exports = (env, argv) => { + return webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY }); +}; diff --git a/modes/dental/.webpack/webpack.prod.js b/modes/dental/.webpack/webpack.prod.js new file mode 100644 index 00000000000..32517c5cf17 --- /dev/null +++ b/modes/dental/.webpack/webpack.prod.js @@ -0,0 +1,52 @@ +const webpack = require('webpack'); +const { merge } = require('webpack-merge'); +const path = require('path'); + +const pkg = require('./../package.json'); +const webpackCommon = require('./../../../.webpack/webpack.base.js'); + +const ROOT_DIR = path.join(__dirname, './../'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); +const ENTRY = { + app: `${SRC_DIR}/index.ts`, +}; + +module.exports = (env, argv) => { + const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY }); + + return merge(commonConfig, { + stats: { + colors: true, + hash: true, + timings: true, + assets: true, + chunks: false, + chunkModules: false, + modules: false, + children: false, + warnings: true, + }, + optimization: { + minimize: true, + sideEffects: false, + }, + output: { + path: ROOT_DIR, + library: 'ohif-mode-dental', + libraryTarget: 'umd', + libraryExport: 'default', + filename: pkg.main, + }, + externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/], + plugins: [ + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1, + }), + // new MiniCssExtractPlugin({ + // filename: './dist/[name].css', + // chunkFilename: './dist/[id].css', + // }), + ], + }); +}; diff --git a/modes/dental/CHANGELOG.md b/modes/dental/CHANGELOG.md new file mode 100644 index 00000000000..b6a26e8e203 --- /dev/null +++ b/modes/dental/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +## Unreleased +- Initial dental mode scaffold. diff --git a/modes/dental/LICENSE b/modes/dental/LICENSE new file mode 100644 index 00000000000..19e20dd35ca --- /dev/null +++ b/modes/dental/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Open Health Imaging Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/modes/dental/README.md b/modes/dental/README.md new file mode 100644 index 00000000000..112a77203d5 --- /dev/null +++ b/modes/dental/README.md @@ -0,0 +1,3 @@ +# Dental Mode + +Dental-focused mode configuration for OHIF Viewer. diff --git a/modes/dental/babel.config.js b/modes/dental/babel.config.js new file mode 100644 index 00000000000..325ca2a8ee7 --- /dev/null +++ b/modes/dental/babel.config.js @@ -0,0 +1 @@ +module.exports = require('../../babel.config.js'); diff --git a/modes/dental/package.json b/modes/dental/package.json new file mode 100644 index 00000000000..35fc7a8a6a5 --- /dev/null +++ b/modes/dental/package.json @@ -0,0 +1,57 @@ +{ + "name": "@ohif/mode-dental", + "version": "3.12.0-beta.127", + "description": "Dental Workflow", + "author": "OHIF", + "license": "MIT", + "repository": "OHIF/Viewers", + "main": "dist/ohif-mode-dental.js", + "module": "src/index.ts", + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1.16.0" + }, + "files": [ + "dist", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "keywords": [ + "ohif-mode" + ], + "scripts": { + "clean": "shx rm -rf dist", + "clean:deep": "yarn run clean && shx rm -rf node_modules", + "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo", + "dev:cornerstone": "yarn run dev", + "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", + "build:package": "yarn run build", + "start": "yarn run dev", + "test:unit": "jest --watchAll", + "test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests" + }, + "peerDependencies": { + "@ohif/core": "3.12.0-beta.127", + "@ohif/extension-cornerstone": "3.12.0-beta.127", + "@ohif/extension-cornerstone-dicom-rt": "3.12.0-beta.127", + "@ohif/extension-cornerstone-dicom-seg": "3.12.0-beta.127", + "@ohif/extension-cornerstone-dicom-sr": "3.12.0-beta.127", + "@ohif/extension-default": "3.12.0-beta.127", + "@ohif/extension-dental": "3.12.0-beta.127", + "@ohif/extension-dicom-pdf": "3.12.0-beta.127", + "@ohif/extension-dicom-video": "3.12.0-beta.127", + "@ohif/extension-measurement-tracking": "3.12.0-beta.127", + "@ohif/mode-basic": "3.12.0-beta.127" + }, + "dependencies": { + "@babel/runtime": "7.28.2", + "i18next": "17.3.1" + }, + "devDependencies": { + "webpack": "5.95.0", + "webpack-merge": "5.10.0" + } +} diff --git a/modes/dental/src/id.js b/modes/dental/src/id.js new file mode 100644 index 00000000000..ebe5acd98ae --- /dev/null +++ b/modes/dental/src/id.js @@ -0,0 +1,5 @@ +import packageJson from '../package.json'; + +const id = packageJson.name; + +export { id }; diff --git a/modes/dental/src/index.ts b/modes/dental/src/index.ts new file mode 100644 index 00000000000..b4153ad020f --- /dev/null +++ b/modes/dental/src/index.ts @@ -0,0 +1,96 @@ +import i18n from 'i18next'; +import { id } from './id'; +import { + initToolGroups, + toolbarButtons, + cornerstone, + ohif, + dicomsr, + dicomvideo, + dicompdf, + dicomSeg, + dicomPmap, + dicomRT, + basicLayout, + basicRoute, + extensionDependencies as basicDependencies, + mode as basicMode, + modeInstance as basicModeInstance, +} from '@ohif/mode-basic'; + +export const extensionDependencies = { + ...basicDependencies, + '@ohif/extension-dental': '^3.0.0', +}; + +// Dental-specific panel references +const dental = { + measurements: '@ohif/extension-dental.panelModule.dentalMeasurements', +}; + +export const dentalLayout = { + ...basicLayout, + id: '@ohif/extension-dental.layoutTemplateModule.dentalViewerLayout', + props: { + ...basicLayout.props, + // Override right panels to use dental measurements panel + rightPanels: [dental.measurements], + rightPanelClosed: false, // Open by default to show measurements palette + viewports: [ + { + namespace: cornerstone.viewport, + displaySetsToDisplay: [ + ohif.sopClassHandler, + dicomvideo.sopClassHandler, + ohif.wsiSopClassHandler, + ], + }, + { + namespace: dicomsr.viewport, + displaySetsToDisplay: [dicomsr.sopClassHandler, dicomsr.sopClassHandler3D], + }, + { + namespace: dicompdf.viewport, + displaySetsToDisplay: [dicompdf.sopClassHandler], + }, + { + namespace: dicomSeg.viewport, + displaySetsToDisplay: [dicomSeg.sopClassHandler], + }, + { + namespace: dicomPmap.viewport, + displaySetsToDisplay: [dicomPmap.sopClassHandler], + }, + { + namespace: dicomRT.viewport, + displaySetsToDisplay: [dicomRT.sopClassHandler], + }, + ], + }, +}; + +export const dentalRoute = { + ...basicRoute, + path: 'dental', + layoutInstance: dentalLayout, +}; + +export const modeInstance = { + ...basicModeInstance, + id, + routeName: 'dental', + displayName: i18n.t('Modes:Dental Viewer'), + routes: [dentalRoute], + extensions: extensionDependencies, + hangingProtocol: '@ohif/hpDental', +}; + +const mode = { + ...basicMode, + id, + modeInstance, + extensionDependencies, +}; + +export default mode; +export { initToolGroups, toolbarButtons }; diff --git a/platform/app/package.json b/platform/app/package.json index cab0461c326..7d946785a7a 100644 --- a/platform/app/package.json +++ b/platform/app/package.json @@ -56,6 +56,7 @@ "@cornerstonejs/dicom-image-loader": "4.15.9", "@emotion/serialize": "1.3.3", "@ohif/core": "3.12.0-beta.127", + "@ohif/extension-dental": "3.12.0-beta.127", "@ohif/extension-cornerstone": "3.12.0-beta.127", "@ohif/extension-cornerstone-dicom-rt": "3.12.0-beta.127", "@ohif/extension-cornerstone-dicom-seg": "3.12.0-beta.127", @@ -68,6 +69,7 @@ "@ohif/extension-ultrasound-pleura-bline": "3.12.0-beta.127", "@ohif/i18n": "3.12.0-beta.127", "@ohif/mode-basic-dev-mode": "3.12.0-beta.127", + "@ohif/mode-dental": "3.12.0-beta.127", "@ohif/mode-longitudinal": "3.12.0-beta.127", "@ohif/mode-microscopy": "3.12.0-beta.127", "@ohif/mode-test": "3.12.0-beta.127", diff --git a/platform/app/pluginConfig.json b/platform/app/pluginConfig.json index fd56a748cf5..fe892f8d26d 100644 --- a/platform/app/pluginConfig.json +++ b/platform/app/pluginConfig.json @@ -65,6 +65,10 @@ { "packageName": "@ohif/extension-ultrasound-pleura-bline", "version": "3.0.0" + }, + { + "packageName": "@ohif/extension-dental", + "version": "3.12.0-beta.127" } ], "modes": [ @@ -99,6 +103,10 @@ { "packageName": "@ohif/mode-ultrasound-pleura-bline", "version": "3.0.0" + }, + { + "packageName": "@ohif/mode-dental", + "version": "3.12.0-beta.127" } ], "public": [ diff --git a/platform/app/src/components/EmptyViewport.tsx b/platform/app/src/components/EmptyViewport.tsx index a6fa6f3acbc..2d7a526cfdd 100644 --- a/platform/app/src/components/EmptyViewport.tsx +++ b/platform/app/src/components/EmptyViewport.tsx @@ -1,7 +1,30 @@ import React from 'react'; -function EmptyViewport() { - return
; +type EmptyViewportProps = { + viewportOptions?: { + customViewportProps?: { + placeholder?: string; + description?: string; + }; + }; +}; + +function EmptyViewport({ viewportOptions }: EmptyViewportProps) { + const placeholder = viewportOptions?.customViewportProps?.placeholder; + const description = viewportOptions?.customViewportProps?.description; + + if (!placeholder) { + return
; + } + + return ( +
+
+
{placeholder}
+ {description ?
{description}
: null} +
+
+ ); } export default EmptyViewport;