ui(components): New Dialog and Modal components for ui-next (#4772)

Co-authored-by: sedghi <ar.sedghi@gmail.com>
This commit is contained in:
Dan Rukas 2025-02-28 15:17:55 -05:00 committed by GitHub
parent 62b4fa3e89
commit a3b51aa8db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
90 changed files with 3459 additions and 1357 deletions

View File

@ -1,5 +1,4 @@
import dcmjs from 'dcmjs';
import { createReportDialogPrompt } from '@ohif/extension-default';
import { Types } from '@ohif/core';
import { cache, metaData } from '@cornerstonejs/core';
import {
@ -8,11 +7,13 @@ import {
utilities,
} from '@cornerstonejs/tools';
import { adaptersRT, helpers, adaptersSEG } from '@cornerstonejs/adapters';
import { createReportDialogPrompt } from '@ohif/extension-default';
import { classes, DicomMetadataStore } from '@ohif/core';
import vtkImageMarchingSquares from '@kitware/vtk.js/Filters/General/ImageMarchingSquares';
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
import PROMPT_RESPONSES from '../../default/src/utils/_shared/PROMPT_RESPONSES';
const { segmentation: segmentationUtils } = utilities;
@ -51,6 +52,7 @@ const commandsModule = ({
displaySetService,
viewportGridService,
toolGroupService,
customizationService,
} = servicesManager.services as AppTypes.Services;
const actions = {
@ -226,14 +228,6 @@ const commandsModule = ({
* otherwise throws an error.
*/
storeSegmentation: async ({ segmentationId, dataSource }) => {
const promptResult = await createReportDialogPrompt(uiDialogService, {
extensionManager,
});
if (promptResult.action !== 1 && !promptResult.value) {
return;
}
const segmentation = segmentationService.getSegmentation(segmentationId);
if (!segmentation) {
@ -241,33 +235,50 @@ const commandsModule = ({
}
const { label } = segmentation;
const SeriesDescription = promptResult.value || label || 'Research Derived Series';
const defaultDataSource = dataSource ?? extensionManager.getActiveDataSource();
const generatedData = actions.generateSegmentation({
segmentationId,
options: {
SeriesDescription,
},
const {
value: reportName,
dataSourceName: selectedDataSource,
action,
} = await createReportDialogPrompt({
servicesManager,
extensionManager,
title: 'Store Segmentation',
});
if (!generatedData || !generatedData.dataset) {
throw new Error('Error during segmentation generation');
if (action === PROMPT_RESPONSES.CREATE_REPORT) {
try {
const selectedDataSourceConfig = selectedDataSource
? extensionManager.getDataSources(selectedDataSource)[0]
: defaultDataSource;
const generatedData = actions.generateSegmentation({
segmentationId,
options: {
SeriesDescription: reportName || label || 'Research Derived Series',
},
});
if (!generatedData || !generatedData.dataset) {
throw new Error('Error during segmentation generation');
}
const { dataset: naturalizedReport } = generatedData;
await selectedDataSourceConfig.store.dicom(naturalizedReport);
// add the information for where we stored it to the instance as well
naturalizedReport.wadoRoot = selectedDataSourceConfig.getConfig().wadoRoot;
DicomMetadataStore.addInstances([naturalizedReport], true);
return naturalizedReport;
} catch (error) {
console.debug('Error storing segmentation:', error);
throw error;
}
}
const { dataset: naturalizedReport } = generatedData;
await dataSource.store.dicom(naturalizedReport);
// The "Mode" route listens for DicomMetadataStore changes
// When a new instance is added, it listens and
// automatically calls makeDisplaySets
// add the information for where we stored it to the instance as well
naturalizedReport.wadoRoot = dataSource.getConfig().wadoRoot;
DicomMetadataStore.addInstances([naturalizedReport], true);
return naturalizedReport;
},
/**
* Converts segmentations into RTSS for download.

View File

@ -146,6 +146,11 @@ const OHIFCornerstoneViewport = React.memo(
const { viewportId, element } = evt.detail;
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
if (!viewportInfo) {
return;
}
setEnabledElement(viewportId, element);
setEnabledVPElement(element);
@ -323,10 +328,10 @@ const OHIFCornerstoneViewport = React.memo(
// Set up the window level action menu in the viewport action corners.
useEffect(() => {
const windowLevelActionMenu = customizationService.getCustomization(
'viewportActionMenu.windowLevelActionMenu'
'viewportActionMenu.windowLevelActionMenu'
);
const segmentationOverlay = customizationService.getCustomization(
'viewportActionMenu.segmentationOverlay'
'viewportActionMenu.segmentationOverlay'
);
if (windowLevelActionMenu?.enabled) {
@ -362,13 +367,7 @@ const OHIFCornerstoneViewport = React.memo(
location: segmentationOverlay.location,
});
}
}, [
displaySets,
viewportId,
viewportActionCornersService,
servicesManager,
commandsManager,
]);
}, [displaySets, viewportId, viewportActionCornersService, servicesManager, commandsManager]);
const { ref: resizeRef } = useResizeDetector({
onResize,

View File

@ -18,21 +18,19 @@ import * as cornerstoneTools from '@cornerstonejs/tools';
import { Types as OhifTypes, utils } from '@ohif/core';
import i18n from '@ohif/i18n';
import {
callLabelAutocompleteDialog,
showLabelAnnotationPopup,
callInputDialogAutoComplete,
createReportAsync,
callInputDialog,
colorPickerDialog,
callInputDialog,
} from '@ohif/extension-default';
import { vec3, mat4 } from 'gl-matrix';
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
import toggleImageSliceSync from './utils/imageSliceSync/toggleImageSliceSync';
import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/utils/selection';
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
import toggleVOISliceSync from './utils/toggleVOISliceSync';
import { usePositionPresentationStore, useSegmentationPresentationStore } from './stores';
import { toolNames } from './initCornerstoneTools';
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
const { DefaultHistoryMemo } = csUtils.HistoryMemo;
const toggleSyncFunctions = {
imageSlice: toggleImageSliceSync,
@ -200,26 +198,55 @@ function commandsModule({
}
},
/**
* Show the measurement labelling input dialog and update the label
* on the measurement with a response if not cancelled.
* Common logic for handling measurement label updates through dialog
* @param uid - measurement uid
* @returns Promise that resolves when the label is updated
*/
setMeasurementLabel: ({ uid }) => {
_handleMeasurementLabelDialog: async uid => {
const labelConfig = customizationService.getCustomization('measurementLabels');
const renderContent = customizationService.getCustomization('ui.labellingComponent');
const measurement = measurementService.getMeasurement(uid);
showLabelAnnotationPopup(measurement, uiDialogService, labelConfig, renderContent).then(
(val: Map<any, any>) => {
measurementService.update(
uid,
{
...val,
},
true
);
}
);
},
if (!measurement) {
console.debug('No measurement found for label editing');
return;
}
if (!labelConfig) {
const label = await callInputDialog({
uiDialogService,
title: 'Edit Measurement Label',
placeholder: measurement.label || 'Enter new label',
defaultValue: measurement.label,
});
if (label !== undefined && label !== null) {
measurementService.update(uid, { ...measurement, label }, true);
}
return;
}
const val = await callInputDialogAutoComplete({
measurement,
uiDialogService,
labelConfig,
renderContent,
});
if (val !== undefined && val !== null) {
measurementService.update(uid, { ...val }, true);
}
},
/**
* Show the measurement labelling input dialog and update the label
* on the measurement with a response if not cancelled.
*/
setMeasurementLabel: async ({ uid }) => {
await actions._handleMeasurementLabelDialog(uid);
},
renameMeasurement: async ({ uid }) => {
await actions._handleMeasurementLabelDialog(uid);
},
/**
*
* @param props - containing the updates to apply
@ -321,23 +348,6 @@ function commandsModule({
measurementService.remove(uid);
},
renameMeasurement: ({ uid }) => {
const labelConfig = customizationService.getCustomization('measurementLabels');
const renderContent = customizationService.getCustomization('ui.labellingComponent');
const measurement = measurementService.getMeasurement(uid);
showLabelAnnotationPopup(measurement, uiDialogService, labelConfig, renderContent).then(
val => {
measurementService.update(
uid,
{
...val,
},
true
);
}
);
},
toggleLockMeasurement: ({ uid }) => {
measurementService.toggleLockMeasurement(uid);
},
@ -375,10 +385,10 @@ function commandsModule({
viewportGridService.setActiveViewportId(viewportId);
},
arrowTextCallback: ({ callback, data, uid }) => {
arrowTextCallback: ({ callback }) => {
const labelConfig = customizationService.getCustomization('measurementLabels');
const renderContent = customizationService.getCustomization('ui.labellingComponent');
callLabelAutocompleteDialog(uiDialogService, callback, {}, labelConfig, renderContent);
callInputDialogAutoComplete(uiDialogService, callback, {}, labelConfig, renderContent);
},
toggleCine: () => {
const { viewports } = viewportGridService.getState();
@ -568,6 +578,7 @@ function commandsModule({
],
});
},
// capture viewport
showDownloadViewportModal: () => {
const { activeViewportId } = viewportGridService.getState();
@ -589,10 +600,9 @@ function commandsModule({
title: 'Download High Quality Image',
contentProps: {
activeViewportId,
onClose: uiModalService.hide,
cornerstoneViewportService,
},
containerDimensions: 'w-[70%] max-w-[900px]',
containerClassName: 'max-w-4xl p-4',
});
}
},
@ -1138,14 +1148,12 @@ function commandsModule({
*/
storeSegmentationCommand: async ({ segmentationId }) => {
const { segmentationService, viewportGridService } = servicesManager.services;
const datasources = extensionManager.getActiveDataSource();
const displaySetInstanceUIDs = await createReportAsync({
servicesManager,
getReport: () =>
commandsManager.runCommand('storeSegmentation', {
segmentationId,
dataSource: datasources[0],
}),
reportType: 'Segmentation',
});
@ -1271,7 +1279,7 @@ function commandsModule({
segmentationService.setStyle({ type }, { fillAlphaInactive: value });
},
editSegmentLabel: ({ segmentationId, segmentIndex }) => {
editSegmentLabel: async ({ segmentationId, segmentIndex }) => {
const { segmentationService, uiDialogService } = servicesManager.services;
const segmentation = segmentationService.getSegmentation(segmentationId);
@ -1280,19 +1288,14 @@ function commandsModule({
}
const segment = segmentation.segments[segmentIndex];
const { label } = segment;
const callback = (label, actionId) => {
if (label === '') {
return;
}
callInputDialog({
uiDialogService,
title: 'Edit Segment Label',
placeholder: 'Enter new label',
defaultValue: segment.label,
}).then(label => {
segmentationService.setSegmentLabel(segmentationId, segmentIndex, label);
};
callInputDialog(uiDialogService, label, callback, false, {
dialogTitle: 'Edit Segment Label',
inputLabel: 'Enter new label',
});
},
@ -1306,17 +1309,13 @@ function commandsModule({
const { label } = segmentation;
const callback = (label, actionId) => {
if (label === '') {
return;
}
callInputDialog({
uiDialogService,
title: 'Edit Segmentation Label',
placeholder: 'Enter new label',
defaultValue: label,
}).then(label => {
segmentationService.addOrUpdateSegmentation({ segmentationId, label });
};
callInputDialog(uiDialogService, label, callback, false, {
dialogTitle: 'Edit Segmentation Label',
inputLabel: 'Enter new label',
});
},
@ -1333,13 +1332,16 @@ function commandsModule({
a: color[3] / 255.0,
};
colorPickerDialog(uiDialogService, rgbaColor, (newRgbaColor, actionId) => {
if (actionId === 'cancel') {
return;
}
const color = [newRgbaColor.r, newRgbaColor.g, newRgbaColor.b, newRgbaColor.a * 255.0];
segmentationService.setSegmentColor(viewportId, segmentationId, segmentIndex, color);
uiDialogService.show({
content: colorPickerDialog,
title: 'Segment Color',
contentProps: {
value: rgbaColor,
onSave: newRgbaColor => {
const color = [newRgbaColor.r, newRgbaColor.g, newRgbaColor.b, newRgbaColor.a * 255.0];
segmentationService.setSegmentColor(viewportId, segmentationId, segmentIndex, color);
},
},
});
},
@ -1395,6 +1397,9 @@ function commandsModule({
setMeasurementLabel: {
commandFn: actions.setMeasurementLabel,
},
renameMeasurement: {
commandFn: actions.renameMeasurement,
},
updateMeasurement: {
commandFn: actions.updateMeasurement,
},
@ -1407,9 +1412,6 @@ function commandsModule({
removeMeasurement: {
commandFn: actions.removeMeasurement,
},
renameMeasurement: {
commandFn: actions.renameMeasurement,
},
toggleLockMeasurement: {
commandFn: actions.toggleLockMeasurement,
},

View File

@ -10,21 +10,19 @@ export function VolumeRenderingPresets({
commandsManager,
volumeRenderingPresets,
}: VolumeRenderingPresetsProps): ReactElement {
const { uiModalService } = servicesManager.services;
const { uiDialogService } = servicesManager.services;
const onClickPresets = () => {
uiModalService.show({
uiDialogService.show({
id: 'volume-rendering-presets',
content: VolumeRenderingPresetsContent,
title: 'Rendering Presets',
movable: true,
isDraggable: true,
contentProps: {
onClose: uiModalService.hide,
presets: volumeRenderingPresets,
viewportId,
commandsManager,
},
containerDimensions: 'h-[543px] w-[460px]',
contentDimensions: 'h-[493px] w-[460px] pl-[12px] pr-[12px]',
});
};

View File

@ -1,29 +1,24 @@
import { ButtonEnums } from '@ohif/ui';
import { Icons } from '@ohif/ui-next';
import { Icons, FooterAction } from '@ohif/ui-next';
import React, { ReactElement, useState, useCallback } from 'react';
import { Button, InputFilterText } from '@ohif/ui';
import { PresetDialog } from '@ohif/ui-next';
import { ViewportPreset, VolumeRenderingPresetsContentProps } from '../../types/ViewportPresets';
interface Props extends VolumeRenderingPresetsContentProps {
hide: () => void;
}
export function VolumeRenderingPresetsContent({
presets,
viewportId,
commandsManager,
onClose,
}: VolumeRenderingPresetsContentProps): ReactElement {
const [filteredPresets, setFilteredPresets] = useState(presets);
hide,
}: Props): ReactElement {
const [searchValue, setSearchValue] = useState('');
const [selectedPreset, setSelectedPreset] = useState<ViewportPreset | null>(null);
const handleSearchChange = useCallback(
(value: string) => {
setSearchValue(value);
const filtered = value
? presets.filter(preset => preset.name.toLowerCase().includes(value.toLowerCase()))
: presets;
setFilteredPresets(filtered);
},
[presets]
);
const handleSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(event.target.value);
}, []);
const handleApply = useCallback(
props => {
@ -34,62 +29,54 @@ export function VolumeRenderingPresetsContent({
[commandsManager]
);
const filteredPresets = searchValue
? presets.filter(preset => preset.name.toLowerCase().includes(searchValue.toLowerCase()))
: presets;
const formatLabel = (label: string, maxChars: number) => {
return label.length > maxChars ? `${label.slice(0, maxChars)}...` : label;
};
return (
<div className="flex min-h-full w-full flex-col justify-between">
<div className="border-secondary-light h-[433px] w-full overflow-hidden rounded border bg-black px-2.5">
<div className="flex h-[46px] w-full items-center justify-start">
<div className="h-[26px] w-[200px]">
<InputFilterText
value={searchValue}
onDebounceChange={handleSearchChange}
placeholder={'Search all'}
/>
</div>
</div>
<div className="ohif-scrollbar overflow h-[385px] w-full overflow-y-auto">
<div className="grid grid-cols-4 gap-3 pt-2 pr-3">
{filteredPresets.map((preset, index) => (
<div
key={index}
className="flex cursor-pointer flex-col items-start"
onClick={() => {
setSelectedPreset(preset);
handleApply({ preset: preset.name, viewportId });
}}
>
<Icons.ByName
name={preset.name}
className={
selectedPreset?.name === preset.name
? 'border-primary-light h-[75px] w-[95px] max-w-none rounded border-2'
: 'hover:border-primary-light h-[75px] w-[95px] max-w-none rounded border-2 border-black'
}
/>
<label className="text-aqua-pale mt-2 text-left text-xs">
{formatLabel(preset.name, 11)}
</label>
</div>
))}
</div>
</div>
</div>
<footer className="flex h-[60px] w-full items-center justify-end">
<div className="flex">
<Button
name="Cancel"
size={ButtonEnums.size.medium}
type={ButtonEnums.type.secondary}
onClick={onClose}
>
{' '}
Cancel{' '}
</Button>
</div>
</footer>
</div>
<PresetDialog className="h-[500px]">
<PresetDialog.PresetBody>
<PresetDialog.PresetFilter>
<PresetDialog.PresetSearch
value={searchValue}
onChange={handleSearchChange}
placeholder="Search all"
/>
</PresetDialog.PresetFilter>
<PresetDialog.PresetGrid>
{filteredPresets.map((preset, index) => (
<div
key={index}
className="flex cursor-pointer flex-col items-start"
onClick={() => {
setSelectedPreset(preset);
handleApply({ preset: preset.name, viewportId });
}}
>
<Icons.ByName
name={preset.name}
className={
selectedPreset?.name === preset.name
? 'border-highlight h-[75px] w-[95px] max-w-none rounded border-2'
: 'hover:border-highlight h-[75px] w-[95px] max-w-none rounded border-2 border-black'
}
/>
<label className="text-muted-foreground mt-1 text-left text-xs">
{formatLabel(preset.name, 11)}
</label>
</div>
))}
</PresetDialog.PresetGrid>
</PresetDialog.PresetBody>
<FooterAction className="mt-4 flex-shrink-0">
<FooterAction.Right>
<FooterAction.Secondary onClick={hide}>Cancel</FooterAction.Secondary>
</FooterAction.Right>
</FooterAction>
</PresetDialog>
);
}

View File

@ -21,8 +21,9 @@ export function getWindowLevelActionMenu({
const { volumeRenderingPresets, volumeRenderingQualityRange } =
customizationService.getCustomization('cornerstone.3dVolumeRendering');
const WindowLevelActionMenu = customizationService.getCustomization(
'cornerstone.windowLevelActionMenu'
'viewportActionMenu.windowLevelActionMenu'
);
console.debug('🚀 ~ WindowLevelActionMenu:', WindowLevelActionMenu);
const displaySetPresets = displaySets
.filter(displaySet => presets[displaySet.Modality])
.map(displaySet => {
@ -37,8 +38,10 @@ export function getWindowLevelActionMenu({
return null;
}
const WindowLevelActionMenuComponent = WindowLevelActionMenu?.component;
return (
<WindowLevelActionMenu
<WindowLevelActionMenuComponent
viewportId={viewportId}
element={element}
presets={displaySetPresets}

View File

@ -0,0 +1,152 @@
import React, { useState, useEffect } from 'react';
import { ImageModal, FooterAction } from '@ohif/ui-next';
const MAX_TEXTURE_SIZE = 10000;
const DEFAULT_FILENAME = 'image';
interface ViewportDownloadFormNewProps {
onClose: () => void;
defaultSize: number;
fileTypeOptions: Array<{ value: string; label: string }>;
viewportId: string;
showAnnotations: boolean;
onAnnotationsChange: (show: boolean) => void;
dimensions: { width: number; height: number };
onDimensionsChange: (dimensions: { width: number; height: number }) => void;
onEnableViewport: (element: HTMLElement) => void;
onDisableViewport: () => void;
onDownload: (filename: string, fileType: string) => void;
warningState: { enabled: boolean; value: string };
}
function ViewportDownloadFormNew({
onClose,
defaultSize,
fileTypeOptions,
viewportId,
showAnnotations,
onAnnotationsChange,
dimensions,
warningState,
onDimensionsChange,
onEnableViewport,
onDisableViewport,
onDownload,
}: ViewportDownloadFormNewProps) {
const [viewportElement, setViewportElement] = useState<HTMLElement | null>(null);
const [showWarningMessage, setShowWarningMessage] = useState(true);
const [filename, setFilename] = useState(DEFAULT_FILENAME);
const [fileType, setFileType] = useState('jpg');
useEffect(() => {
if (!viewportElement) {
return;
}
onEnableViewport(viewportElement);
return () => {
onDisableViewport();
};
}, [onDisableViewport, onEnableViewport, viewportElement]);
return (
<ImageModal>
<ImageModal.Body>
<ImageModal.ImageVisual>
<div
style={{
height: dimensions.height,
width: dimensions.width,
position: 'relative',
}}
data-viewport-uid={viewportId}
ref={setViewportElement}
>
{warningState.enabled && showWarningMessage && (
<div
className="text-foreground absolute left-1/2 bottom-[5px] z-[1000] -translate-x-1/2 whitespace-nowrap rounded bg-black p-3 text-xs font-bold"
style={{
fontSize: '12px',
}}
>
{warningState.value}
</div>
)}
</div>
</ImageModal.ImageVisual>
<ImageModal.ImageOptions>
<div className="flex items-end space-x-2">
<ImageModal.Filename
value={filename}
onChange={e => setFilename(e.target.value)}
>
File name
</ImageModal.Filename>
<ImageModal.Filetype
selected={fileType}
onSelect={setFileType}
options={fileTypeOptions}
/>
</div>
<ImageModal.ImageSize
width={dimensions.width.toString()}
height={dimensions.height.toString()}
onWidthChange={e => {
onDimensionsChange({
...dimensions,
width: parseInt(e.target.value) || defaultSize,
});
}}
onHeightChange={e => {
onDimensionsChange({
...dimensions,
height: parseInt(e.target.value) || defaultSize,
});
}}
maxWidth={MAX_TEXTURE_SIZE.toString()}
maxHeight={MAX_TEXTURE_SIZE.toString()}
>
Image size <span className="text-muted-foreground">px</span>
</ImageModal.ImageSize>
<ImageModal.SwitchOption
defaultChecked={showAnnotations}
checked={showAnnotations}
onCheckedChange={onAnnotationsChange}
>
Include annotations
</ImageModal.SwitchOption>
{warningState.enabled && (
<ImageModal.SwitchOption
defaultChecked={showWarningMessage}
checked={showWarningMessage}
onCheckedChange={setShowWarningMessage}
>
Include warning message
</ImageModal.SwitchOption>
)}
<FooterAction className="mt-2">
<FooterAction.Right>
<FooterAction.Secondary onClick={onClose}>Cancel</FooterAction.Secondary>
<FooterAction.Primary
onClick={() => {
onDownload(filename || DEFAULT_FILENAME, fileType);
onClose();
}}
>
Save
</FooterAction.Primary>
</FooterAction.Right>
</FooterAction>
</ImageModal.ImageOptions>
</ImageModal.Body>
</ImageModal>
);
}
export default {
'ohif.captureViewportModal': ViewportDownloadFormNew,
};

View File

@ -1,9 +1,11 @@
import viewportActionCornersService from '../services/ViewportActionCornersService/ViewportActionCornersService';
import { WindowLevelActionMenu } from '../components/WindowLevelActionMenu/WindowLevelActionMenu';
export default {
'viewportActionMenu.windowLevelActionMenu': {
enabled: true,
location: viewportActionCornersService.LOCATIONS.topRight,
component: WindowLevelActionMenu,
},
'viewportActionMenu.segmentationOverlay': {
enabled: true,

View File

@ -0,0 +1,6 @@
export default {
'viewportDownload.warningMessage': {
enabled: true,
value: 'Not For Diagnostic Use',
},
};

View File

@ -1,5 +0,0 @@
import { WindowLevelActionMenu } from '../components/WindowLevelActionMenu/WindowLevelActionMenu';
export default {
'cornerstone.windowLevelActionMenu': WindowLevelActionMenu,
};

View File

@ -8,7 +8,8 @@ import volumeRenderingCustomization from './customizations/volumeRenderingCustom
import colorbarCustomization from './customizations/colorbarCustomization';
import windowLevelPresetsCustomization from './customizations/windowLevelPresetsCustomization';
import miscCustomization from './customizations/miscCustomization';
import windowLevelActionMenuCustomization from './customizations/windowLevelActionMenuCustomization';
import captureViewportModalCustomization from './customizations/captureViewportModalCustomization';
import viewportDownloadWarningCustomization from './customizations/viewportDownloadWarningCustomization';
import viewportActionMenuCustomizations from './customizations/viewportActionMenuCustomizations';
function getCustomizationModule({ commandsManager, servicesManager }) {
@ -26,7 +27,8 @@ function getCustomizationModule({ commandsManager, servicesManager }) {
...colorbarCustomization,
...windowLevelPresetsCustomization,
...miscCustomization,
...windowLevelActionMenuCustomization,
...captureViewportModalCustomization,
...viewportDownloadWarningCustomization,
...viewportActionMenuCustomizations,
},
},

View File

@ -54,6 +54,7 @@ import PanelMeasurement from './panels/PanelMeasurement';
import DicomUpload from './components/DicomUpload/DicomUpload';
import { useSegmentations } from './hooks/useSegmentations';
import { StudySummaryFromMetadata } from './components/StudySummaryFromMetadata';
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
import utils from './utils';
const { imageRetrieveMetadataProvider } = cornerstone.utilities;
@ -255,6 +256,7 @@ export {
PanelMeasurement,
DicomUpload,
StudySummaryFromMetadata,
CornerstoneViewportDownloadForm,
utils,
};
export default cornerstoneExtension;

View File

@ -84,35 +84,14 @@ export function onCompletedCalibrationLine(
return;
}
callInputDialog(
callInputDialog({
uiDialogService,
{
text: '',
label: `${length}`,
},
(value, id) => {
if (id === 'save') {
adjustCalibration(Number.parseFloat(value));
resolve(true);
} else {
reject('cancel');
}
},
false,
{
dialogTitle: 'Calibration',
inputLabel: 'Actual Physical distance (mm)',
// the input value must be a number
validateFunc: val => {
try {
const v = Number.parseFloat(val);
return !isNaN(v) && v !== 0.0;
} catch {
return false;
}
},
}
);
title: 'Calibration',
placeholder: 'Actual Physical distance (mm)',
defaultValue: `${length}`,
}).then(newValue => {
adjustCalibration(Number.parseFloat(newValue));
resolve(true);
});
});
}

View File

@ -1,53 +1,66 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import html2canvas from 'html2canvas';
import {
Enums,
getEnabledElement,
getOrCreateCanvas,
StackViewport,
BaseVolumeViewport,
} from '@cornerstonejs/core';
import { getEnabledElement, StackViewport, BaseVolumeViewport } from '@cornerstonejs/core';
import { ToolGroupManager } from '@cornerstonejs/tools';
import { ViewportDownloadForm } from '@ohif/ui';
import { getEnabledElement as OHIFgetEnabledElement } from '../state';
import { useSystem } from '@ohif/core/src';
const MINIMUM_SIZE = 100;
const DEFAULT_SIZE = 512;
const MAX_TEXTURE_SIZE = 10000;
const VIEWPORT_ID = 'cornerstone-viewport-download-form';
const FILE_TYPE_OPTIONS = [
{
value: 'jpg',
label: 'JPG',
},
{
value: 'png',
label: 'PNG',
},
];
type ViewportDownloadFormProps = {
hide: () => void;
activeViewportId: string;
};
const CornerstoneViewportDownloadForm = ({
onClose,
hide,
activeViewportId: activeViewportIdProp,
cornerstoneViewportService,
}: withAppTypes) => {
const enabledElement = OHIFgetEnabledElement(activeViewportIdProp);
const activeViewportElement = enabledElement?.element;
const activeViewportEnabledElement = getEnabledElement(activeViewportElement);
}: ViewportDownloadFormProps) => {
const { servicesManager } = useSystem();
const { customizationService, cornerstoneViewportService } = servicesManager.services;
const [showAnnotations, setShowAnnotations] = useState(true);
const [viewportDimensions, setViewportDimensions] = useState({
width: DEFAULT_SIZE,
height: DEFAULT_SIZE,
});
const {
viewportId: activeViewportId,
renderingEngineId,
viewport: activeViewport,
} = activeViewportEnabledElement;
const warningState = customizationService.getCustomization('viewportDownload.warningMessage') as {
enabled: boolean;
value: string;
};
const refViewportEnabledElementOHIF = OHIFgetEnabledElement(activeViewportIdProp);
const activeViewportElement = refViewportEnabledElementOHIF?.element;
const { viewportId: activeViewportId, renderingEngineId } =
getEnabledElement(activeViewportElement);
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
const toolGroup = ToolGroupManager.getToolGroupForViewport(activeViewportId, renderingEngineId);
const toolModeAndBindings = Object.keys(toolGroup.toolOptions).reduce((acc, toolName) => {
const tool = toolGroup.toolOptions[toolName];
const { mode, bindings } = tool;
return {
...acc,
[toolName]: {
mode,
bindings,
},
};
}, {});
useEffect(() => {
const toolModeAndBindings = Object.keys(toolGroup.toolOptions).reduce((acc, toolName) => {
const tool = toolGroup.toolOptions[toolName];
const { mode, bindings } = tool;
return {
...acc,
[toolName]: { mode, bindings },
};
}, {});
return () => {
Object.keys(toolModeAndBindings).forEach(toolName => {
const { mode, bindings } = toolModeAndBindings[toolName];
@ -56,157 +69,92 @@ const CornerstoneViewportDownloadForm = ({
};
}, []);
const enableViewport = viewportElement => {
if (viewportElement) {
const { renderingEngine, viewport } = getEnabledElement(activeViewportElement);
const viewportInput = {
viewportId: VIEWPORT_ID,
element: viewportElement,
type: viewport.type,
defaultOptions: {
background: viewport.defaultOptions.background,
orientation: viewport.defaultOptions.orientation,
},
};
renderingEngine.enableElement(viewportInput);
}
};
const disableViewport = viewportElement => {
if (viewportElement) {
const { renderingEngine } = getEnabledElement(viewportElement);
return new Promise(resolve => {
renderingEngine.disableElement(VIEWPORT_ID);
});
}
};
const updateViewportPreview = (downloadViewportElement, internalCanvas, fileType) =>
new Promise(resolve => {
const enabledElement = getEnabledElement(downloadViewportElement);
const { viewport: downloadViewport, renderingEngine } = enabledElement;
// Note: Since any trigger of dimensions will update the viewport,
// we need to resize the offScreenCanvas to accommodate for the new
// dimensions, this is due to the reason that we are using the GPU offScreenCanvas
// to render the viewport for the downloadViewport.
renderingEngine.resize();
// Trigger the render on the viewport to update the on screen
// downloadViewport.resetCamera();
downloadViewport.render();
downloadViewportElement.addEventListener(
Enums.Events.IMAGE_RENDERED,
function updateViewport(event) {
const enabledElement = getEnabledElement(event.target);
const { viewport } = enabledElement;
const { element } = viewport;
const downloadCanvas = getOrCreateCanvas(element);
const type = 'image/' + fileType;
const dataUrl = downloadCanvas.toDataURL(type, 1);
let newWidth = element.offsetHeight;
let newHeight = element.offsetWidth;
if (newWidth > DEFAULT_SIZE || newHeight > DEFAULT_SIZE) {
const multiplier = DEFAULT_SIZE / Math.max(newWidth, newHeight);
newHeight *= multiplier;
newWidth *= multiplier;
}
resolve({ dataUrl, width: newWidth, height: newHeight });
downloadViewportElement.removeEventListener(Enums.Events.IMAGE_RENDERED, updateViewport);
// for some reason we need a reset camera here, and I don't know why
downloadViewport.resetCamera();
const presentation = activeViewport.getViewPresentation();
if (downloadViewport.setView) {
downloadViewport.setView(activeViewport.getViewReference(), presentation);
}
downloadViewport.render();
}
);
});
const loadImage = (activeViewportElement, viewportElement, width, height) =>
new Promise(resolve => {
if (activeViewportElement && viewportElement) {
const activeViewportEnabledElement = getEnabledElement(activeViewportElement);
if (!activeViewportEnabledElement) {
return;
}
const { viewport } = activeViewportEnabledElement;
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
const downloadViewport = renderingEngine.getViewport(VIEWPORT_ID);
if (downloadViewport instanceof StackViewport) {
const imageId = viewport.getCurrentImageId();
const properties = viewport.getProperties();
downloadViewport.setStack([imageId]).then(() => {
try {
downloadViewport.setProperties(properties);
const newWidth = Math.min(width || image.width, MAX_TEXTURE_SIZE);
const newHeight = Math.min(height || image.height, MAX_TEXTURE_SIZE);
resolve({ width: newWidth, height: newHeight });
} catch (e) {
// Happens on clicking the cancel button
console.warn('Unable to set properties', e);
}
});
} else if (downloadViewport instanceof BaseVolumeViewport) {
const actors = viewport.getActors();
// downloadViewport.setActors(actors);
actors.forEach(actor => {
downloadViewport.addActor(actor);
});
downloadViewport.render();
const newWidth = Math.min(width || image.width, MAX_TEXTURE_SIZE);
const newHeight = Math.min(height || image.height, MAX_TEXTURE_SIZE);
resolve({ width: newWidth, height: newHeight });
}
}
});
const toggleAnnotations = (toggle, viewportElement, activeViewportElement) => {
const activeViewportEnabledElement = getEnabledElement(activeViewportElement);
const downloadViewportElement = getEnabledElement(viewportElement);
const { viewportId: activeViewportId, renderingEngineId } = activeViewportEnabledElement;
const { viewportId: downloadViewportId } = downloadViewportElement;
if (!activeViewportEnabledElement || !downloadViewportElement) {
const handleEnableViewport = (viewportElement: HTMLElement) => {
if (!viewportElement) {
return;
}
const toolGroup = ToolGroupManager.getToolGroupForViewport(activeViewportId, renderingEngineId);
const { viewport } = getEnabledElement(activeViewportElement);
// add the viewport to the toolGroup
const viewportInput = {
viewportId: VIEWPORT_ID,
element: viewportElement,
type: viewport.type,
defaultOptions: {
background: viewport.defaultOptions.background,
orientation: viewport.defaultOptions.orientation,
},
};
renderingEngine.enableElement(viewportInput);
};
const handleDisableViewport = async () => {
renderingEngine.disableElement(VIEWPORT_ID);
};
const handleLoadImage = async (width: number, height: number) => {
if (!activeViewportElement) {
return;
}
const activeViewportEnabledElement = getEnabledElement(activeViewportElement);
if (!activeViewportEnabledElement) {
return;
}
const { viewport } = activeViewportEnabledElement;
const downloadViewport = renderingEngine.getViewport(VIEWPORT_ID);
try {
if (downloadViewport instanceof StackViewport) {
const imageId = viewport.getCurrentImageId();
const properties = viewport.getProperties();
await downloadViewport.setStack([imageId]);
downloadViewport.setProperties(properties);
return {
width: Math.min(width || DEFAULT_SIZE, MAX_TEXTURE_SIZE),
height: Math.min(height || DEFAULT_SIZE, MAX_TEXTURE_SIZE),
};
} else if (downloadViewport instanceof BaseVolumeViewport) {
const volumeIds = viewport.getAllVolumeIds();
downloadViewport.setVolumes([{ volumeId: volumeIds[0] }]);
return {
width: Math.min(width || DEFAULT_SIZE, MAX_TEXTURE_SIZE),
height: Math.min(height || DEFAULT_SIZE, MAX_TEXTURE_SIZE),
};
}
} catch (error) {
console.error('Error loading image:', error);
}
};
const handleToggleAnnotations = (show: boolean) => {
const activeViewportEnabledElement = getEnabledElement(activeViewportElement);
if (!activeViewportEnabledElement) {
return;
}
const downloadViewport = renderingEngine.getViewport(VIEWPORT_ID);
if (!downloadViewport) {
return;
}
const { viewportId: activeViewportId, renderingEngineId } = activeViewportEnabledElement;
const { id: downloadViewportId } = downloadViewport;
const toolGroup = ToolGroupManager.getToolGroupForViewport(activeViewportId, renderingEngineId);
toolGroup.addViewport(downloadViewportId, renderingEngineId);
Object.keys(toolGroup.getToolInstances()).forEach(toolName => {
// make all tools Enabled so that they can not be interacted with
// in the download viewport
if (toggle && toolName !== 'Crosshairs') {
if (show && toolName !== 'Crosshairs') {
try {
toolGroup.setToolEnabled(toolName);
} catch (e) {
console.log(e);
} catch (error) {
console.debug('Error enabling tool:', error);
}
} else {
toolGroup.setToolDisabled(toolName);
@ -214,33 +162,50 @@ const CornerstoneViewportDownloadForm = ({
});
};
const downloadBlob = (filename, fileType) => {
const file = `${filename}.${fileType}`;
useEffect(() => {
if (viewportDimensions.width && viewportDimensions.height) {
setTimeout(() => {
handleLoadImage(viewportDimensions.width, viewportDimensions.height);
handleToggleAnnotations(showAnnotations);
}, 100);
}
}, [viewportDimensions, showAnnotations]);
const handleDownload = async (filename: string, fileType: string) => {
const divForDownloadViewport = document.querySelector(
`div[data-viewport-uid="${VIEWPORT_ID}"]`
);
html2canvas(divForDownloadViewport).then(canvas => {
const link = document.createElement('a');
link.download = file;
link.href = canvas.toDataURL(fileType, 1.0);
link.click();
});
if (!divForDownloadViewport) {
console.debug('No viewport found for download');
return;
}
const canvas = await html2canvas(divForDownloadViewport as HTMLElement);
const link = document.createElement('a');
link.download = `${filename}.${fileType}`;
link.href = canvas.toDataURL(`image/${fileType}`, 1.0);
link.click();
};
const ViewportDownloadFormNew = customizationService.getCustomization(
'ohif.captureViewportModal'
);
return (
<ViewportDownloadForm
onClose={onClose}
minimumSize={MINIMUM_SIZE}
maximumSize={MAX_TEXTURE_SIZE}
<ViewportDownloadFormNew
onClose={hide}
defaultSize={DEFAULT_SIZE}
activeViewportElement={activeViewportElement}
enableViewport={enableViewport}
disableViewport={disableViewport}
updateViewportPreview={updateViewportPreview}
loadImage={loadImage}
toggleAnnotations={toggleAnnotations}
downloadBlob={downloadBlob}
fileTypeOptions={FILE_TYPE_OPTIONS}
viewportId={VIEWPORT_ID}
showAnnotations={showAnnotations}
onAnnotationsChange={setShowAnnotations}
dimensions={viewportDimensions}
onDimensionsChange={setViewportDimensions}
onEnableViewport={handleEnableViewport}
onDisableViewport={handleDisableViewport}
onDownload={handleDownload}
warningState={warningState}
/>
);
};

View File

@ -11,17 +11,13 @@ async function createReportAsync({
reportType = 'measurement',
}: withAppTypes) {
const { displaySetService, uiNotificationService, uiDialogService } = servicesManager.services;
const loadingDialogId = uiDialogService.create({
showOverlay: true,
isDraggable: false,
centralize: true,
content: Loading,
});
try {
const naturalizedReport = await getReport();
if (!naturalizedReport) return;
if (!naturalizedReport) {
return;
}
// The "Mode" route listens for DicomMetadataStore changes
// When a new instance is added, it listens and
@ -47,12 +43,8 @@ async function createReportAsync({
});
throw new Error(`Failed to store ${reportType}. Error: ${error.message || 'Unknown error'}`);
} finally {
uiDialogService.dismiss({ id: loadingDialogId });
uiDialogService.hide('loading-dialog');
}
}
function Loading() {
return <div className="text-primary-active">Loading...</div>;
}
export default createReportAsync;

View File

@ -1,7 +1,6 @@
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useModal } from '@ohif/ui';
import { Icons } from '@ohif/ui-next';
import { Icons, useModal } from '@ohif/ui-next';
import { Types } from '@ohif/core';
import DataSourceConfigurationModalComponent from './DataSourceConfigurationModalComponent';

View File

@ -25,7 +25,7 @@ export default class ContextMenuController {
}
closeContextMenu() {
this.services.uiDialogService.dismiss({ id: 'context-menu' });
this.services.uiDialogService.hide('context-menu');
}
/**
@ -71,26 +71,24 @@ export default class ContextMenuController {
menuId
);
if (!items) {
return;
}
const ContextMenu = this.services.customizationService.getCustomization('ui.contextMenu');
this.services.uiDialogService.dismiss({ id: 'context-menu' });
this.services.uiDialogService.create({
this.services.uiDialogService.hide('context-menu');
this.services.uiDialogService.show({
id: 'context-menu',
isDraggable: false,
preservePosition: false,
preventCutOf: true,
defaultPosition: ContextMenuController._getDefaultPosition(
defaultPointsPosition,
event?.detail || event,
viewportElement
),
event,
content: ContextMenu,
// This naming is part of the uiDialogService convention
// Clicking outside simply closes the dialog box.
onClickOutside: () => this.services.uiDialogService.dismiss({ id: 'context-menu' }),
shouldCloseOnEsc: true,
shouldCloseOnOverlayClick: true,
unstyled: true,
contentProps: {
items,
selectorProps,
@ -100,7 +98,7 @@ export default class ContextMenuController {
eventData: event?.detail || event,
onClose: () => {
this.services.uiDialogService.dismiss({ id: 'context-menu' });
this.services.uiDialogService.hide('context-menu');
},
/**

View File

@ -24,8 +24,6 @@
.dicom-tag-browser-content {
overflow: hidden;
width: 100%;
padding-bottom: 50px;
/*height: 500px;*/
}

View File

@ -176,6 +176,7 @@ const DicomTagBrowser = ({
<InputFilterText
placeholder="Search metadata..."
onDebounceChange={setFilterValue}
className="text-foreground"
/>
</div>
</div>

View File

@ -39,7 +39,7 @@ const RowComponent = ({
<div
style={{ ...style, ...rowStyle }}
className={classNames(
'hover:bg-secondary-main border-secondary-light flex w-full flex-row items-center break-all bg-black text-base transition duration-300',
'hover:bg-secondary-main border-secondary-light text-foreground flex w-full flex-row items-center break-all bg-black text-base transition duration-300',
lineHeightClassName
)}
key={keyPrefix}
@ -291,7 +291,7 @@ function DicomTagTable({ rows }: { rows: Row[] }) {
itemCount={visibleRows.length}
itemSize={getItemSize(visibleRows)}
width={'100%'}
className="ohif-scrollbar"
className="ohif-scrollbar text-foreground"
>
{getRowComponent({ rows: visibleRows })}
</List>

View File

@ -1,132 +1,40 @@
import React from 'react';
import { ButtonEnums, Dialog, Input, Select } from '@ohif/ui';
import PROMPT_RESPONSES from '../utils/_shared/PROMPT_RESPONSES';
export default function CreateReportDialogPrompt(uiDialogService, { extensionManager }) {
export default function CreateReportDialogPrompt({
title = 'Create Report',
extensionManager,
servicesManager,
}): Promise<{
value: string;
dataSourceName: string;
action: (typeof PROMPT_RESPONSES)[keyof typeof PROMPT_RESPONSES];
}> {
const { uiDialogService, customizationService } = servicesManager.services;
const dataSources = extensionManager.getDataSourcesForUI();
const ReportDialog = customizationService.getCustomization('ohif.createReportDialog');
const allowMultipleDataSources = window.config.allowMultiSelectExport;
return new Promise(function (resolve, reject) {
let dialogId = undefined;
const _handleClose = () => {
// Dismiss dialog
uiDialogService.dismiss({ id: dialogId });
// Notify of cancel action
resolve({
action: PROMPT_RESPONSES.CANCEL,
value: undefined,
dataSourceName: undefined,
});
};
/**
*
* @param {string} param0.action - value of action performed
* @param {string} param0.value - value from input field
*/
const _handleFormSubmit = ({ action, value }) => {
uiDialogService.dismiss({ id: dialogId });
switch (action.id) {
case 'save':
uiDialogService.show({
id: 'report-dialog',
title,
content: ReportDialog,
contentProps: {
dataSources: allowMultipleDataSources ? dataSources : undefined,
onSave: async ({ reportName, dataSource: selectedDataSource }) => {
resolve({
value: reportName,
dataSourceName: selectedDataSource,
action: PROMPT_RESPONSES.CREATE_REPORT,
value: value.label,
dataSourceName: value.dataSourceName,
});
break;
case 'cancel':
},
onCancel: () => {
resolve({
action: PROMPT_RESPONSES.CANCEL,
value: undefined,
dataSourceName: undefined,
});
break;
}
};
const dataSourcesOpts = Object.keys(extensionManager.dataSourceMap)
.filter(ds => {
const configuration = extensionManager.dataSourceDefs[ds]?.configuration;
const supportsStow = configuration?.supportsStow ?? configuration?.wadoRoot;
return supportsStow;
})
.map(ds => {
return {
value: ds,
label: ds,
placeHolder: ds,
};
});
dialogId = uiDialogService.create({
centralize: true,
isDraggable: false,
content: Dialog,
useLastPosition: false,
showOverlay: true,
contentProps: {
title: 'Create Report',
value: {
label: '',
dataSourceName: extensionManager.activeDataSource,
},
noCloseButton: true,
onClose: _handleClose,
actions: [
{ id: 'cancel', text: 'Cancel', type: ButtonEnums.type.secondary },
{ id: 'save', text: 'Save', type: ButtonEnums.type.primary },
],
// TODO: Should be on button press...
onSubmit: _handleFormSubmit,
body: ({ value, setValue }) => {
const onChangeHandler = event => {
event.persist();
setValue(value => ({ ...value, label: event.target.value }));
};
const onKeyPressHandler = event => {
if (event.key === 'Enter') {
uiDialogService.dismiss({ id: dialogId });
resolve({
action: PROMPT_RESPONSES.CREATE_REPORT,
value: value.label,
});
}
};
return (
<>
{dataSourcesOpts.length > 1 && window.config?.allowMultiSelectExport && (
<div>
<label className="text-[14px] leading-[1.2] text-white">Data Source</label>
<Select
closeMenuOnSelect={true}
className="border-primary-main mt-2 bg-black"
options={dataSourcesOpts}
placeholder={
dataSourcesOpts.find(option => option.value === value.dataSourceName)
.placeHolder
}
value={value.dataSourceName}
onChange={evt => {
setValue(v => ({ ...v, dataSourceName: evt.value }));
}}
isClearable={false}
/>
</div>
)}
<div className="mt-3">
<Input
autoFocus
label="Enter the report name"
labelClassName="text-white text-[14px] leading-[1.2]"
className="border-primary-main bg-black"
type="text"
value={value.label}
onChange={onChangeHandler}
onKeyPress={onKeyPressHandler}
required
/>
</div>
</>
);
},
},
});

View File

@ -2,23 +2,17 @@ import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { UserPreferences, AboutModal, useModal } from '@ohif/ui';
import { Header } from '@ohif/ui-next';
import i18n from '@ohif/i18n';
import { hotkeys } from '@ohif/core';
import { Header, useModal } from '@ohif/ui-next';
import { useSystem } from '@ohif/core';
import { Toolbar } from '../Toolbar/Toolbar';
import HeaderPatientInfo from './HeaderPatientInfo';
import { PatientInfoVisibility } from './HeaderPatientInfo/HeaderPatientInfo';
import { preserveQueryParameters, publicUrl } from '@ohif/app';
const { availableLanguages, defaultLanguage, currentLanguage } = i18n;
function ViewerHeader({ appConfig }: withAppTypes<{ appConfig: AppTypes.Config }>) {
const { servicesManager, extensionManager } = useSystem();
const { customizationService } = servicesManager.services;
function ViewerHeader({
hotkeysManager,
extensionManager,
servicesManager,
appConfig,
}: withAppTypes<{ appConfig: AppTypes.Config }>) {
const navigate = useNavigate();
const location = useLocation();
@ -42,10 +36,10 @@ function ViewerHeader({
};
const { t } = useTranslation();
const { show, hide } = useModal();
const { hotkeyDefinitions, hotkeyDefaults } = hotkeysManager;
const versionNumber = process.env.VERSION_NUMBER;
const commitHash = process.env.COMMIT_HASH;
const { show } = useModal();
const AboutModal = customizationService.getCustomization('ohif.aboutModal');
const UserPreferencesModal = customizationService.getCustomization('ohif.userPreferencesModal');
const menuOptions = [
{
@ -55,8 +49,7 @@ function ViewerHeader({
show({
content: AboutModal,
title: t('AboutModal:About OHIF Viewer'),
contentProps: { versionNumber, commitHash },
containerDimensions: 'max-w-4xl max-h-4xl',
containerClassName: 'max-w-md',
}),
},
{
@ -64,30 +57,9 @@ function ViewerHeader({
icon: 'settings',
onClick: () =>
show({
content: UserPreferencesModal,
title: t('UserPreferencesModal:User preferences'),
content: UserPreferences,
containerDimensions: 'w-[70%] max-w-[900px]',
contentProps: {
hotkeyDefaults: hotkeysManager.getValidHotkeyDefinitions(hotkeyDefaults),
hotkeyDefinitions,
currentLanguage: currentLanguage(),
availableLanguages,
defaultLanguage,
onCancel: () => {
hotkeys.stopRecord();
hotkeys.unpause();
hide();
},
onSubmit: ({ hotkeyDefinitions, language }) => {
if (language.value !== currentLanguage().value) {
i18n.changeLanguage(language.value);
}
hotkeysManager.setHotkeys(hotkeyDefinitions);
hide();
},
onReset: () => hotkeysManager.restoreDefaultBindings(),
hotkeysModule: hotkeys,
},
containerClassName: 'flex max-w-4xl p-6 flex-col',
}),
},
];

View File

@ -505,10 +505,9 @@ const commandsModule = ({
contentProps: {
displaySets,
displaySetInstanceUID: defaultDisplaySetInstanceUID,
onClose: UIModalService.hide,
},
containerDimensions: 'w-[70%] max-w-[900px]',
title: 'DICOM Tag Browser',
containerClassName: 'max-w-3xl',
});
},

View File

@ -0,0 +1,40 @@
import React from 'react';
import { AboutModal } from '@ohif/ui-next';
import detect from 'browser-detect';
function AboutModalDefault() {
const { os, version, name } = detect();
const browser = `${name[0].toUpperCase()}${name.substr(1)} ${version}`;
const versionNumber = process.env.VERSION_NUMBER;
const commitHash = process.env.COMMIT_HASH;
const [main, beta] = versionNumber.split('-');
return (
<AboutModal className="w-[400px]">
<AboutModal.ProductName>OHIF Viewer</AboutModal.ProductName>
<AboutModal.ProductVersion>{main}</AboutModal.ProductVersion>
{beta && <AboutModal.ProductBeta>{beta}</AboutModal.ProductBeta>}
<AboutModal.Body>
<AboutModal.DetailItem
label="Commit Hash"
value={commitHash}
/>
<AboutModal.DetailItem
label="Current Browser & OS"
value={`${browser}, ${os}`}
/>
<AboutModal.SocialItem
icon="SocialGithub"
url="OHIF/Viewers"
text="github.com/OHIF/Viewers"
/>
</AboutModal.Body>
</AboutModal>
);
}
export default {
'ohif.aboutModal': AboutModalDefault,
};

View File

@ -0,0 +1,91 @@
import React, { useState } from 'react';
import { InputDialog } from '@ohif/ui-next';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ohif/ui-next';
type DataSource = {
value: string;
label: string;
placeHolder: string;
};
type ReportDialogProps = {
dataSources: DataSource[];
hide: () => void;
onSave: (data: { reportName: string; dataSource: string | null }) => void;
onCancel: () => void;
};
function ReportDialog({ dataSources, hide, onSave, onCancel }: ReportDialogProps) {
const [selectedDataSource, setSelectedDataSource] = useState<string | null>(
dataSources?.[0]?.value ?? null
);
const handleSave = (reportName: string) => {
onSave({
reportName,
dataSource: selectedDataSource,
});
hide();
};
const handleCancel = () => {
onCancel();
hide();
};
const showDataSourceSelect = dataSources?.length > 1;
return (
<div className="text-foreground mt-2 flex min-w-[400px] max-w-md flex-col gap-4">
<div className="flex flex-col gap-3">
<div className={showDataSourceSelect ? 'flex gap-4' : ''}>
{showDataSourceSelect && (
<div className="mt-1 w-1/3">
<Select
value={selectedDataSource}
onValueChange={setSelectedDataSource}
>
<SelectTrigger>
<SelectValue placeholder="Select a data source" />
</SelectTrigger>
<SelectContent>
{dataSources.map(source => (
<SelectItem
key={source.value}
value={source.value}
>
{source.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className={showDataSourceSelect ? 'mt-1 w-2/3' : 'w-full'}>
<InputDialog>
<InputDialog.Field>
<InputDialog.Input placeholder="Report name" />
</InputDialog.Field>
</InputDialog>
</div>
</div>
<div className="flex justify-end gap-2">
<InputDialog>
<InputDialog.Actions>
<InputDialog.ActionsSecondary onClick={handleCancel}>
Cancel
</InputDialog.ActionsSecondary>
<InputDialog.ActionsPrimary onClick={handleSave}>Save</InputDialog.ActionsPrimary>
</InputDialog.Actions>
</InputDialog>
</div>
</div>
</div>
);
}
export { ReportDialog };
export default {
'ohif.createReportDialog': ReportDialog,
};

View File

@ -0,0 +1,140 @@
import React, { useState } from 'react';
import { useSystem, hotkeys as hotkeysModule } from '@ohif/core';
import { UserPreferencesModal, FooterAction } from '@ohif/ui-next';
import { useTranslation } from 'react-i18next';
import i18n from '@ohif/i18n';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@ohif/ui-next';
const { availableLanguages, defaultLanguage, currentLanguage: currentLanguageFn } = i18n;
interface HotkeyDefinition {
keys: string;
label: string;
}
interface HotkeyDefinitions {
[key: string]: HotkeyDefinition;
}
function UserPreferencesModalDefault({ hide }: { hide: () => void }) {
const { hotkeysManager } = useSystem();
const { t } = useTranslation('UserPreferencesModal');
const { hotkeyDefinitions = {}, hotkeyDefaults = {} } = hotkeysManager;
const currentLanguage = currentLanguageFn();
const [state, setState] = useState({
hotkeyDefinitions: hotkeyDefinitions as HotkeyDefinitions,
languageValue: currentLanguage.value,
});
const onLanguageChangeHandler = (value: string) => {
setState(state => ({ ...state, languageValue: value }));
};
const onHotkeyChangeHandler = (id: string, newKeys: string) => {
setState(state => ({
...state,
hotkeyDefinitions: {
...state.hotkeyDefinitions,
[id]: {
...state.hotkeyDefinitions[id],
keys: newKeys,
},
},
}));
};
const onResetHandler = () => {
setState(state => ({
...state,
languageValue: defaultLanguage.value,
hotkeyDefinitions: hotkeyDefaults as HotkeyDefinitions,
}));
hotkeysManager.restoreDefaultBindings();
};
return (
<UserPreferencesModal>
<UserPreferencesModal.Body>
{/* Language Section */}
<div className="mb-3 flex items-center space-x-14">
<UserPreferencesModal.SubHeading>{t('Language')}</UserPreferencesModal.SubHeading>
<Select
defaultValue={state.languageValue}
onValueChange={onLanguageChangeHandler}
>
<SelectTrigger
className="w-60"
aria-label="Language"
>
<SelectValue placeholder={t('Select language')} />
</SelectTrigger>
<SelectContent>
{availableLanguages.map(lang => (
<SelectItem
key={lang.value}
value={lang.value}
>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<UserPreferencesModal.SubHeading>{t('Hotkeys')}</UserPreferencesModal.SubHeading>
<UserPreferencesModal.HotkeysGrid>
{Object.entries(state.hotkeyDefinitions).map(([id, definition]) => (
<UserPreferencesModal.Hotkey
key={id}
label={t(definition.label)}
value={definition.keys}
onChange={newKeys => onHotkeyChangeHandler(id, newKeys)}
placeholder={definition.keys}
hotkeys={hotkeysModule}
/>
))}
</UserPreferencesModal.HotkeysGrid>
</UserPreferencesModal.Body>
<FooterAction>
<FooterAction.Left>
<FooterAction.Auxiliary onClick={onResetHandler}>
{t('Reset to defaults')}
</FooterAction.Auxiliary>
</FooterAction.Left>
<FooterAction.Right>
<FooterAction.Secondary
onClick={() => {
hotkeysModule.stopRecord();
hotkeysModule.unpause();
hide();
}}
>
{t('Cancel')}
</FooterAction.Secondary>
<FooterAction.Primary
onClick={() => {
if (state.languageValue !== currentLanguage.value) {
i18n.changeLanguage(state.languageValue);
}
hotkeysManager.setHotkeys(state.hotkeyDefinitions);
hotkeysModule.stopRecord();
hotkeysModule.unpause();
hide();
}}
>
{t('Save')}
</FooterAction.Primary>
</FooterAction.Right>
</FooterAction>
</UserPreferencesModal>
);
}
export default {
'ohif.userPreferencesModal': UserPreferencesModalDefault,
};

View File

@ -18,6 +18,9 @@ import progressLoadingBarCustomization from './customizations/progressLoadingBar
import viewportActionCornersCustomization from './customizations/viewportActionCornersCustomization';
import labellingFlowCustomization from './customizations/labellingFlowCustomization';
import viewportNotificationCustomization from './customizations/notificationCustomization';
import aboutModalCustomization from './customizations/aboutModalCustomization';
import userPreferencesCustomization from './customizations/userPreferencesCustomization';
import reportDialogCustomization from './customizations/reportDialogCustomization';
import hotkeyBindingsCustomization from './customizations/hotkeyBindingsCustomization';
import onboardingCustomization from './customizations/onboardingCustomization';
/**
@ -63,6 +66,9 @@ export default function getCustomizationModule({ servicesManager, extensionManag
...labellingFlowCustomization,
...contextMenuUICustomization,
...viewportNotificationCustomization,
...aboutModalCustomization,
...userPreferencesCustomization,
...reportDialogCustomization,
...hotkeyBindingsCustomization,
...onboardingCustomization,
},

View File

@ -12,9 +12,10 @@ import getCustomizationModule from './getCustomizationModule';
import getViewportModule from './getViewportModule';
import { id } from './id';
import preRegistration from './init';
import { createReportDialogPrompt } from './Panels';
import { ContextMenuController, CustomizableContextMenuTypes } from './CustomizableContextMenu';
import * as dicomWebUtils from './DicomWebDataSource/utils';
import { createReportDialogPrompt } from './Panels';
import createReportAsync from './Actions/createReportAsync';
import StaticWadoClient from './DicomWebDataSource/utils/StaticWadoClient';
import { cleanDenaturalizedDataset } from './DicomWebDataSource/utils';
@ -25,11 +26,7 @@ import { useDisplaySetSelectorStore } from './stores/useDisplaySetSelectorStore'
import { useHangingProtocolStageIndexStore } from './stores/useHangingProtocolStageIndexStore';
import { useToggleHangingProtocolStore } from './stores/useToggleHangingProtocolStore';
import { useToggleOneUpViewportGridStore } from './stores/useToggleOneUpViewportGridStore';
import {
callLabelAutocompleteDialog,
showLabelAnnotationPopup,
callInputDialog,
} from './utils/callInputDialog';
import { callInputDialogAutoComplete, callInputDialog } from './utils/callInputDialog';
import colorPickerDialog from './utils/colorPickerDialog';
import promptSaveReport from './utils/promptSaveReport';
@ -82,7 +79,6 @@ export {
CustomizableContextMenuTypes,
getStudiesForPatientByMRN,
dicomWebUtils,
createReportDialogPrompt,
createReportAsync,
StaticWadoClient,
cleanDenaturalizedDataset,
@ -94,9 +90,7 @@ export {
useUIStateStore,
useViewportGridStore,
useViewportsByPositionStore,
showLabelAnnotationPopup,
callLabelAutocompleteDialog,
callInputDialog,
callInputDialogAutoComplete,
promptSaveReport,
promptLabelAnnotation,
colorPickerDialog,
@ -105,4 +99,6 @@ export {
utils,
MoreDropdownMenu,
requestDisplaySetCreationForStudy,
callInputDialog,
createReportDialogPrompt,
};

View File

@ -1,160 +1,107 @@
import React from 'react';
import { Input, Dialog, ButtonEnums, LabellingFlow } from '@ohif/ui';
import { LabellingFlow } from '@ohif/ui';
import { InputDialog } from '@ohif/ui-next';
interface InputDialogDefaultProps {
hide: () => void;
onSave: (value: string) => void;
placeholder: string;
defaultValue: string;
submitOnEnter: boolean;
}
function InputDialogDefault({
hide,
onSave,
placeholder = 'Enter value',
defaultValue = '',
submitOnEnter,
}: InputDialogDefaultProps) {
return (
<InputDialog
submitOnEnter={submitOnEnter}
defaultValue={defaultValue}
>
<InputDialog.Field>
<InputDialog.Input placeholder={placeholder} />
</InputDialog.Field>
<InputDialog.Actions>
<InputDialog.ActionsSecondary onClick={hide}>Cancel</InputDialog.ActionsSecondary>
<InputDialog.ActionsPrimary
onClick={value => {
onSave(value);
hide();
}}
>
Save
</InputDialog.ActionsPrimary>
</InputDialog.Actions>
</InputDialog>
);
}
/**
*
* @param {*} data
* @param {*} data.text
* @param {*} data.label
* @param {*} event
* @param {*} callback
* @param {*} isArrowAnnotateInputDialog
* @param {*} dialogConfig
* @param {string?} dialogConfig.dialogTitle - title of the input dialog
* @param {string?} dialogConfig.inputLabel - show label above the input
* Shows an input dialog for entering text with customizable options
* @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 function callInputDialog(
export async function callInputDialog({
uiDialogService,
data,
callback,
isArrowAnnotateInputDialog = true,
dialogConfig: any = {}
) {
defaultValue = '',
title = 'Annotation',
placeholder = '',
submitOnEnter = true,
}: {
uiDialogService: AppTypes.UIDialogService;
}) {
const dialogId = 'dialog-enter-annotation';
const label = data ? (isArrowAnnotateInputDialog ? data.text : data.label) : '';
const {
dialogTitle = 'Annotation',
inputLabel = 'Enter your annotation',
validateFunc = value => true,
} = dialogConfig;
const onSubmitHandler = ({ action, value }) => {
switch (action.id) {
case 'save':
if (typeof validateFunc === 'function' && !validateFunc(value.label)) {
return;
}
callback(value.label, action.id);
break;
case 'cancel':
callback('', action.id);
break;
}
uiDialogService.dismiss({ id: dialogId });
};
if (uiDialogService) {
uiDialogService.create({
const value = await new Promise<string>(resolve => {
uiDialogService.show({
id: dialogId,
centralize: true,
isDraggable: false,
showOverlay: true,
content: Dialog,
content: InputDialogDefault,
title: title,
shouldCloseOnEsc: true,
contentProps: {
title: dialogTitle,
value: { label },
noCloseButton: true,
onClose: () => uiDialogService.dismiss({ id: dialogId }),
actions: [
{ id: 'cancel', text: 'Cancel', type: ButtonEnums.type.secondary },
{ id: 'save', text: 'Save', type: ButtonEnums.type.primary },
],
onSubmit: onSubmitHandler,
body: ({ value, setValue }) => {
return (
<Input
autoFocus
className="border-primary-main bg-black"
type="text"
id="annotation"
label={inputLabel}
labelClassName="text-white text-[14px] leading-[1.2]"
value={value.label}
onChange={event => {
event.persist();
setValue(value => ({ ...value, label: event.target.value }));
}}
onKeyPress={event => {
if (event.key === 'Enter') {
onSubmitHandler({ value, action: { id: 'save' } });
}
}}
/>
);
onSave: value => {
resolve(value);
},
placeholder,
defaultValue,
submitOnEnter,
},
});
}
}
export function callLabelAutocompleteDialog(
uiDialogService,
callback,
dialogConfig,
labelConfig,
renderContent = LabellingFlow
) {
const exclusive = labelConfig ? labelConfig.exclusive : false;
const dropDownItems = labelConfig ? labelConfig.items : [];
const { validateFunc = value => true } = dialogConfig;
const labellingDoneCallback = value => {
if (typeof value === 'string') {
if (typeof validateFunc === 'function' && !validateFunc(value)) {
return;
}
callback(value, 'save');
} else {
callback('', 'cancel');
}
uiDialogService.dismiss({ id: 'select-annotation' });
};
uiDialogService.create({
id: 'select-annotation',
centralize: true,
isDraggable: false,
showOverlay: true,
content: renderContent,
contentProps: {
labellingDoneCallback: labellingDoneCallback,
measurementData: { label: '' },
componentClassName: {},
labelData: dropDownItems,
exclusive: exclusive,
},
});
return value;
}
export function showLabelAnnotationPopup(
export async function callInputDialogAutoComplete({
measurement,
uiDialogService,
labelConfig,
renderContent = LabellingFlow
) {
renderContent = LabellingFlow,
}) {
const exclusive = labelConfig ? labelConfig.exclusive : false;
const dropDownItems = labelConfig ? labelConfig.items : [];
return new Promise<Map<any, any>>((resolve, reject) => {
const value = await new Promise<Map<string, string>>((resolve, reject) => {
const labellingDoneCallback = value => {
uiDialogService.dismiss({ id: 'select-annotation' });
uiDialogService.hide('select-annotation');
if (typeof value === 'string') {
measurement.label = value;
}
resolve(measurement);
};
uiDialogService.create({
uiDialogService.show({
id: 'select-annotation',
isDraggable: false,
showOverlay: true,
title: 'Annotation',
content: renderContent,
defaultPosition: {
x: window.innerWidth / 2,
y: window.innerHeight / 2,
},
contentProps: {
labellingDoneCallback: labellingDoneCallback,
measurementData: measurement,
@ -164,6 +111,8 @@ export function showLabelAnnotationPopup(
},
});
});
return value;
}
export default callInputDialog;

View File

@ -1,58 +1,39 @@
import React from 'react';
import { Dialog } from '@ohif/ui';
import React, { useState } from 'react';
import { ChromePicker } from 'react-color';
import { FooterAction } from '@ohif/ui-next';
import './colorPickerDialog.css';
function colorPickerDialog(uiDialogService, rgbaColor, callback) {
const dialogId = 'pick-color';
function ColorPickerDialog({ value, hide, onSave }) {
const [color, setColor] = useState(value);
const onSubmitHandler = ({ action, value }) => {
switch (action.id) {
case 'save':
callback(value.rgbaColor, action.id);
break;
case 'cancel':
callback('', action.id);
break;
}
uiDialogService.dismiss({ id: dialogId });
const handleChange = color => {
setColor(color.rgb);
};
if (uiDialogService) {
uiDialogService.create({
id: dialogId,
centralize: true,
isDraggable: false,
showOverlay: true,
content: Dialog,
contentProps: {
title: 'Segment Color',
value: { rgbaColor },
noCloseButton: true,
onClose: () => uiDialogService.dismiss({ id: dialogId }),
actions: [
{ id: 'cancel', text: 'Cancel', type: 'primary' },
{ id: 'save', text: 'Save', type: 'secondary' },
],
onSubmit: onSubmitHandler,
body: ({ value, setValue }) => {
const handleChange = color => {
setValue({ rgbaColor: color.rgb });
};
return (
<ChromePicker
color={value.rgbaColor}
onChange={handleChange}
presetColors={[]}
width={300}
/>
);
},
},
});
}
return (
<div>
<ChromePicker
color={color}
onChange={handleChange}
presetColors={[]}
width={300}
/>
<FooterAction>
<FooterAction.Right>
<FooterAction.Secondary onClick={hide}>Cancel</FooterAction.Secondary>
<FooterAction.Primary
onClick={() => {
hide();
onSave(color);
}}
>
Save
</FooterAction.Primary>
</FooterAction.Right>
</FooterAction>
</div>
);
}
export default colorPickerDialog;
export default ColorPickerDialog;

View File

@ -1,42 +1,46 @@
import { showLabelAnnotationPopup } from './callInputDialog';
import { callInputDialogAutoComplete } from './callInputDialog';
function promptLabelAnnotation({ servicesManager }, ctx, evt) {
const { measurementService, customizationService, toolGroupService } = servicesManager.services;
const { measurementService, customizationService, toolGroupService, uiDialogService } =
servicesManager.services;
const { viewportId, StudyInstanceUID, SeriesInstanceUID, measurementId, toolName } = evt;
return new Promise(async function (resolve) {
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
const activeToolOptions = toolGroup.getToolConfiguration(toolName);
if(activeToolOptions.getTextCallback) {
resolve({
StudyInstanceUID,
SeriesInstanceUID,
viewportId,
})
} else {
const labelConfig = customizationService.getCustomization('measurementLabels');
const measurement = measurementService.getMeasurement(measurementId);
const renderContent = customizationService.getCustomization('ui.labellingComponent');
const value = await showLabelAnnotationPopup(
measurement,
servicesManager.services.uiDialogService,
labelConfig,
renderContent
);
return new Promise(resolve => {
(async () => {
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
const activeToolOptions = toolGroup.getToolConfiguration(toolName);
if (activeToolOptions.getTextCallback) {
resolve({
StudyInstanceUID,
SeriesInstanceUID,
viewportId,
});
} else {
const labelConfig = customizationService.getCustomization('measurementLabels');
const measurement = measurementService.getMeasurement(measurementId);
const renderContent = customizationService.getCustomization('ui.labellingComponent');
measurementService.update(
measurementId,
{
...value,
},
true
);
const value = await callInputDialogAutoComplete({
measurement,
uiDialogService,
labelConfig,
renderContent,
});
resolve({
StudyInstanceUID,
SeriesInstanceUID,
viewportId,
});
}
measurementService.update(
measurementId,
{
...value,
},
true
);
resolve({
StudyInstanceUID,
SeriesInstanceUID,
viewportId,
});
}
})();
});
}

View File

@ -1,75 +0,0 @@
import createReportAsync from '../Actions/createReportAsync';
import { createReportDialogPrompt } from '../Panels';
import getNextSRSeriesNumber from './getNextSRSeriesNumber';
import PROMPT_RESPONSES from './_shared/PROMPT_RESPONSES';
async function promptSaveReport({ servicesManager, commandsManager, extensionManager }, ctx, evt) {
const { uiDialogService, measurementService, displaySetService } = servicesManager.services;
const viewportId = evt.viewportId === undefined ? evt.data.viewportId : evt.viewportId;
const isBackupSave = evt.isBackupSave === undefined ? evt.data.isBackupSave : evt.isBackupSave;
const StudyInstanceUID = evt?.data?.StudyInstanceUID;
const SeriesInstanceUID = evt?.data?.SeriesInstanceUID;
const { trackedStudy, trackedSeries } = ctx;
let displaySetInstanceUIDs;
try {
const promptResult = await createReportDialogPrompt(uiDialogService, {
extensionManager,
});
if (promptResult.action === PROMPT_RESPONSES.CREATE_REPORT) {
const dataSources = extensionManager.getDataSources();
const dataSource = dataSources[0];
const measurements = measurementService.getMeasurements();
const trackedMeasurements = measurements
.filter(
m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID)
)
.filter(m => m.referencedImageId != null);
const SeriesDescription =
// isUndefinedOrEmpty
promptResult.value === undefined || promptResult.value === ''
? 'Research Derived Series' // default
: promptResult.value; // provided value
const SeriesNumber = getNextSRSeriesNumber(displaySetService);
const getReport = async () => {
return commandsManager.runCommand(
'storeMeasurements',
{
measurementData: trackedMeasurements,
dataSource,
additionalFindingTypes: ['ArrowAnnotate'],
options: {
SeriesDescription,
SeriesNumber,
},
},
'CORNERSTONE_STRUCTURED_REPORT'
);
};
displaySetInstanceUIDs = await createReportAsync({
servicesManager,
getReport,
});
} else if (promptResult.action === RESPONSE.CANCEL) {
// Do nothing
}
return {
userResponse: promptResult.action,
createdDisplaySetInstanceUIDs: displaySetInstanceUIDs,
StudyInstanceUID,
SeriesInstanceUID,
viewportId,
isBackupSave,
};
} catch (error) {
return null;
}
}
export default promptSaveReport;

View File

@ -0,0 +1,109 @@
import createReportAsync from '../Actions/createReportAsync';
import getNextSRSeriesNumber from './getNextSRSeriesNumber';
import PROMPT_RESPONSES from './_shared/PROMPT_RESPONSES';
import createReportDialogPrompt from '../Panels/createReportDialogPrompt';
/**
* Prompts the user to save a report and handles the report creation process
* @param services - Object containing required services and managers
* @param ctx - The current context containing tracked study and series information
* @param evt - The event object containing viewport and save-related data
*/
async function promptSaveReport(services, ctx, evt) {
const { servicesManager, extensionManager, commandsManager } = services;
const { measurementService, displaySetService } = servicesManager.services;
const viewportId = evt.viewportId ?? evt.data?.viewportId;
const isBackupSave = evt.isBackupSave ?? evt.data?.isBackupSave;
const { StudyInstanceUID, SeriesInstanceUID } = evt?.data ?? {};
const { trackedStudy, trackedSeries } = ctx;
const dataSources = extensionManager.getDataSources();
const {
value: reportName,
dataSourceName: dataSource,
action,
} = await createReportDialogPrompt({
servicesManager,
extensionManager,
});
let displaySetInstanceUIDs;
try {
if (action === PROMPT_RESPONSES.CREATE_REPORT) {
const selectedDataSource = dataSource ?? dataSources[0];
const trackedMeasurements = getTrackedMeasurements(
measurementService,
trackedStudy,
trackedSeries
);
const SeriesNumber = getNextSRSeriesNumber(displaySetService);
displaySetInstanceUIDs = await handleReportCreation({
servicesManager,
commandsManager,
trackedMeasurements,
selectedDataSource,
reportName,
SeriesNumber,
});
}
return {
userResponse: action,
createdDisplaySetInstanceUIDs: displaySetInstanceUIDs,
StudyInstanceUID,
SeriesInstanceUID,
viewportId,
isBackupSave,
};
} catch (error) {
return null;
}
}
/**
* Gets tracked measurements based on study and series criteria
*/
function getTrackedMeasurements(measurementService, trackedStudy, trackedSeries) {
return measurementService
.getMeasurements()
.filter(
m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID)
)
.filter(m => m.referencedImageId != null);
}
/**
* Handles the creation of the report using the measurement service
*/
async function handleReportCreation({
servicesManager,
commandsManager,
trackedMeasurements,
selectedDataSource,
reportName,
SeriesNumber,
}) {
return createReportAsync({
servicesManager,
getReport: () =>
commandsManager.runCommand(
'storeMeasurements',
{
measurementData: trackedMeasurements,
dataSource: selectedDataSource,
additionalFindingTypes: ['ArrowAnnotate'],
options: {
SeriesDescription: reportName,
SeriesNumber,
},
},
'CORNERSTONE_STRUCTURED_REPORT'
),
});
}
export default promptSaveReport;

View File

@ -1,14 +1,13 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { callInputDialog } from '@ohif/extension-default';
import { ExtensionManager, CommandsManager, DicomMetadataStore } from '@ohif/core';
import { MeasurementTable } from '@ohif/ui';
import { withTranslation, WithTranslation } from 'react-i18next';
import { EVENTS as MicroscopyEvents } from '../../services/MicroscopyService';
import dcmjs from 'dcmjs';
import { callInputDialog } from '@ohif/extension-default';
import constructSR from '../../utils/constructSR';
import { saveByteArray } from '../../utils/saveByteArray';
import { Separator } from '@ohif/ui-next';
let saving = false;
const { datasetToBuffer } = dcmjs.data;
@ -140,12 +139,8 @@ function MicroscopyPanel(props: IMicroscopyPanelProps) {
uiDialogService,
title: 'Enter description of the Series',
defaultValue: '',
callback: (value: string, action: string) => {
switch (action) {
case 'save': {
saveFunction(value);
}
}
onSave: (value: string) => {
saveFunction(value);
},
});
};

View File

@ -29,13 +29,9 @@ export default function getCommandsModule({
callInputDialog({
uiDialogService,
defaultValue: '',
callback: (value: string, action: string) => {
switch (action) {
case 'save': {
roiAnnotation.setLabel(value);
microscopyService.triggerRelabel(roiAnnotation);
}
}
onSave: (value: string) => {
roiAnnotation.setLabel(value);
microscopyService.triggerRelabel(roiAnnotation);
},
});
},

View File

@ -11,8 +11,14 @@ import { useTrackedMeasurements } from '../../getContextModule';
import { Separator } from '@ohif/ui-next';
import { MoreDropdownMenu, PanelStudyBrowserHeader } from '@ohif/extension-default';
import { defaultActionIcons } from './constants';
import { UntrackSeriesModal } from './untrackSeriesModal';
const { formatDate, createStudyBrowserTabs } = utils;
const DIALOG_ID = {
UNTRACK_SERIES: 'untrack-series',
REJECT_REPORT: 'ds-reject-sr',
};
const thumbnailNoImageModalities = [
'SR',
'SEG',
@ -44,6 +50,7 @@ export default function PanelStudyBrowserTracking({
measurementService,
studyPrefetcherService,
customizationService,
uiModalService,
} = servicesManager.services;
const navigate = useNavigate();
const studyMode = customizationService.getCustomization('studyBrowser.studyMode');
@ -446,47 +453,11 @@ export default function PanelStudyBrowserTracking({
});
};
uiDialogService.create({
id: 'untrack-series',
centralize: true,
isDraggable: false,
showOverlay: true,
content: Dialog,
uiModalService.show({
title: 'Untrack Series',
content: UntrackSeriesModal,
contentProps: {
title: 'Untrack Series',
body: () => (
<div className="bg-primary-dark p-4 text-white">
<p>Are you sure you want to untrack this series?</p>
<p className="mt-2">
This action cannot be undone and will delete all your existing measurements.
</p>
</div>
),
actions: [
{
id: 'cancel',
text: 'Cancel',
type: ButtonEnums.type.secondary,
},
{
id: 'yes',
text: 'Yes',
type: ButtonEnums.type.primary,
classes: ['untrack-yes-button'],
},
],
onClose: () => uiDialogService.dismiss({ id: 'untrack-series' }),
onSubmit: async ({ action }) => {
switch (action.id) {
case 'yes':
onConfirm();
uiDialogService.dismiss({ id: 'untrack-series' });
break;
case 'cancel':
uiDialogService.dismiss({ id: 'untrack-series' });
break;
}
},
onConfirm,
},
});
};
@ -630,77 +601,6 @@ function _mapDisplaySets(
isHydratedForDerivedDisplaySet: ds.isHydrated,
};
if (componentType === 'thumbnailNoImage') {
if (dataSource.reject && dataSource.reject.series) {
thumbnailProps.canReject = !ds?.unsupported;
thumbnailProps.onReject = () => {
uiDialogService.create({
id: 'ds-reject-sr',
centralize: true,
isDraggable: false,
showOverlay: true,
content: Dialog,
contentProps: {
title: 'Delete Report',
body: () => (
<div className="bg-primary-dark p-4 text-white">
<p>Are you sure you want to delete this report?</p>
<p className="mt-2">This action cannot be undone.</p>
</div>
),
actions: [
{
id: 'cancel',
text: 'Cancel',
type: ButtonEnums.type.secondary,
},
{
id: 'yes',
text: 'Yes',
type: ButtonEnums.type.primary,
classes: ['reject-yes-button'],
},
],
onClose: () => uiDialogService.dismiss({ id: 'ds-reject-sr' }),
onShow: () => {
const yesButton = document.querySelector('.reject-yes-button');
yesButton.focus();
},
onSubmit: async ({ action }) => {
switch (action.id) {
case 'yes':
try {
await dataSource.reject.series(ds.StudyInstanceUID, ds.SeriesInstanceUID);
displaySetService.deleteDisplaySet(displaySetInstanceUID);
uiDialogService.dismiss({ id: 'ds-reject-sr' });
uiNotificationService.show({
title: 'Delete Report',
message: 'Report deleted successfully',
type: 'success',
});
} catch (error) {
uiDialogService.dismiss({ id: 'ds-reject-sr' });
uiNotificationService.show({
title: 'Delete Report',
message: 'Failed to delete report',
type: 'error',
});
}
break;
case 'cancel':
uiDialogService.dismiss({ id: 'ds-reject-sr' });
break;
}
},
},
});
};
} else {
thumbnailProps.canReject = false;
}
}
array.push(thumbnailProps);
});

View File

@ -0,0 +1,28 @@
import React from 'react';
import { FooterAction } from '@ohif/ui-next';
export function UntrackSeriesModal({ hide, onConfirm }) {
return (
<div className="text-foreground">
<div>
<p>Are you sure you want to untrack this series?</p>
<p className="mt-2">
This action cannot be undone and will delete all your existing measurements.
</p>
</div>
<FooterAction className="mt-4">
<FooterAction.Right>
<FooterAction.Secondary onClick={hide}>Cancel</FooterAction.Secondary>
<FooterAction.Primary
onClick={() => {
onConfirm();
hide();
}}
>
Untrack
</FooterAction.Primary>
</FooterAction.Right>
</FooterAction>
</div>
);
}

View File

@ -1,63 +0,0 @@
import React from 'react';
import { Input, Dialog, ButtonEnums } from '@ohif/ui';
function segmentationItemEditHandler({ id, servicesManager }: withAppTypes) {
const { segmentationService, uiDialogService } = servicesManager.services;
const segmentation = segmentationService.getSegmentation(id);
const onSubmitHandler = ({ action, value }) => {
switch (action.id) {
case 'save': {
segmentationService.addOrUpdateSegmentation({
...segmentation,
...value,
});
}
}
uiDialogService.dismiss({ id: 'enter-annotation' });
};
uiDialogService.create({
id: 'enter-annotation',
centralize: true,
isDraggable: false,
showOverlay: true,
content: Dialog,
contentProps: {
title: 'Enter your Segmentation',
noCloseButton: true,
value: { label: segmentation.label || '' },
body: ({ value, setValue }) => {
const onChangeHandler = event => {
event.persist();
setValue(value => ({ ...value, label: event.target.value }));
};
const onKeyPressHandler = event => {
if (event.key === 'Enter') {
onSubmitHandler({ value, action: { id: 'save' } });
}
};
return (
<Input
autoFocus
className="border-primary-main bg-black"
type="text"
containerClassName="mr-2"
value={value.label}
onChange={onChangeHandler}
onKeyPress={onKeyPressHandler}
/>
);
},
actions: [
{ id: 'cancel', text: 'Cancel', type: ButtonEnums.type.secondary },
{ id: 'save', text: 'Save', type: ButtonEnums.type.primary },
],
onSubmit: onSubmitHandler,
},
});
}
export default segmentationItemEditHandler;

View File

@ -105,14 +105,8 @@ function modeFactory({ modeConfiguration }) {
]);
},
onModeExit: ({ servicesManager }: withAppTypes) => {
const {
toolGroupService,
measurementService,
toolbarService,
uiDialogService,
uiModalService,
} = servicesManager.services;
uiDialogService.dismissAll();
const { toolGroupService, uiDialogService, uiModalService } = servicesManager.services;
uiDialogService.hideAll();
uiModalService.hide();
toolGroupService.destroy();
},

View File

@ -128,7 +128,7 @@ function modeFactory() {
uiModalService,
} = servicesManager.services;
uiDialogService.dismissAll();
uiDialogService.hideAll();
uiModalService.hide();
toolGroupService.destroy();
syncGroupService.destroy();

View File

@ -147,7 +147,7 @@ function modeFactory({ modeConfiguration }) {
_activatePanelTriggersSubscriptions.forEach(sub => sub.unsubscribe());
_activatePanelTriggersSubscriptions = [];
uiDialogService.dismissAll();
uiDialogService.hideAll();
uiModalService.hide();
toolGroupService.destroy();
syncGroupService.destroy();

View File

@ -12,12 +12,7 @@ const colorsByOrientation = {
coronal: 'rgb(0, 200, 0)',
};
function initDefaultToolGroup(
extensionManager,
toolGroupService,
commandsManager,
toolGroupId
) {
function initDefaultToolGroup(extensionManager, toolGroupService, commandsManager, toolGroupId) {
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.tools'
);
@ -308,12 +303,7 @@ function initVolume3DToolGroup(extensionManager, toolGroupService) {
}
function initToolGroups(extensionManager, toolGroupService, commandsManager) {
initDefaultToolGroup(
extensionManager,
toolGroupService,
commandsManager,
'default'
);
initDefaultToolGroup(extensionManager, toolGroupService, commandsManager, 'default');
initSRToolGroup(extensionManager, toolGroupService);
initMPRToolGroup(extensionManager, toolGroupService, commandsManager);
initVolume3DToolGroup(extensionManager, toolGroupService);

View File

@ -57,7 +57,7 @@ function modeFactory({ modeConfiguration }) {
onModeExit: ({ servicesManager }: withAppTypes) => {
const { toolbarService, uiDialogService, uiModalService } = servicesManager.services;
uiDialogService.dismissAll();
uiDialogService.hideAll();
uiModalService.hide();
toolbarService.reset();
},

View File

@ -81,7 +81,7 @@ function modeFactory({ modeConfiguration }) {
uiModalService,
} = servicesManager.services;
uiDialogService.dismissAll();
uiDialogService.hideAll();
uiModalService.hide();
toolGroupService.destroy();
syncGroupService.destroy();

View File

@ -154,7 +154,7 @@ function modeFactory({ modeConfiguration }) {
} = servicesManager.services;
unsubscriptions.forEach(unsubscribe => unsubscribe());
uiDialogService.dismissAll();
uiDialogService.hideAll();
uiModalService.hide();
toolGroupService.destroy();
syncGroupService.destroy();

View File

@ -6,7 +6,13 @@ window.config = {
// whiteLabeling: {},
extensions: [],
modes: [],
customizationService: {},
customizationService: {
'viewportActionMenu.windowLevelActionMenu': {
$merge: {
location: 0,
},
},
},
showStudyList: true,
// some windows systems have issues with more than 3 web workers
maxNumberOfWebWorkers: 3,
@ -17,6 +23,7 @@ window.config = {
experimentalStudyBrowserSort: false,
strictZSpacingForVolumeViewport: true,
groupEnabledModesFirst: true,
allowMultiSelectExport: true,
maxNumRequests: {
interaction: 100,
thumbnail: 75,

View File

@ -15,9 +15,6 @@ import {
SystemContextProvider,
} from '@ohif/core';
import {
DialogProvider,
Modal,
ModalProvider,
ThemeWrapper,
ViewportDialogProvider,
CineProvider,
@ -27,8 +24,12 @@ import {
ThemeWrapper as ThemeWrapperNext,
NotificationProvider,
ViewportGridProvider,
DialogProvider,
TooltipProvider,
ToolboxProvider,
Modal as ModalNext,
ManagedDialog,
ModalProvider,
} from '@ohif/ui-next';
// Viewer Project
// TODO: Should this influence study list?
@ -122,8 +123,8 @@ function App({
[CineProvider, { service: cineService }],
[NotificationProvider, { service: uiNotificationService }],
[TooltipProvider],
[DialogProvider, { service: uiDialogService }],
[ModalProvider, { service: uiModalService, modal: Modal }],
[DialogProvider, { service: uiDialogService, dialog: ManagedDialog }],
[ModalProvider, { service: uiModalService, modal: ModalNext }],
[ShepherdJourneyProvider],
];

View File

@ -199,7 +199,6 @@ function DataSourceWrapper(props: withAppTypes) {
servicesManager.services.uiModalService.show({
title: 'Data Source Connection Error',
containerDimensions: 'w-1/2',
content: () => {
return (
<div>

View File

@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
import filtersMeta from './filtersMeta.js';
import { useAppConfig } from '@state';
import { useDebounce, useSearchParams } from '@hooks';
import { utils, hotkeys } from '@ohif/core';
import { utils } from '@ohif/core';
import publicUrl from '../../utils/publicUrl';
import {
@ -19,9 +19,6 @@ import {
StudyListTable,
StudyListPagination,
StudyListFilter,
useModal,
AboutModal,
UserPreferences,
useSessionStorage,
InvestigationalUseDialog,
Button,
@ -35,21 +32,19 @@ import {
TooltipTrigger,
TooltipContent,
Clipboard,
useModal,
Onboarding,
ScrollArea,
} from '@ohif/ui-next';
import { Types } from '@ohif/ui';
import i18n from '@ohif/i18n';
import { preserveQueryParameters, preserveQueryStrings } from '../../utils/preserveQueryParameters';
const PatientInfoVisibility = Types.PatientInfoVisibility;
const { sortBySeriesDate } = utils;
const { availableLanguages, defaultLanguage, currentLanguage } = i18n;
const seriesInStudiesMap = new Map();
/**
@ -102,6 +97,7 @@ function WorkList({
const sortModifier = sortDirection === 'descending' ? 1 : -1;
const defaultSortValues =
shouldUseDefaultSort && canSort ? { sortBy: 'studyDate', sortDirection: 'ascending' } : {};
const { customizationService } = servicesManager.services;
const sortedStudies = useMemo(() => {
if (!canSort) {
@ -470,8 +466,9 @@ function WorkList({
});
const hasStudies = numOfStudies > 0;
const versionNumber = process.env.VERSION_NUMBER;
const commitHash = process.env.COMMIT_HASH;
const AboutModal = customizationService.getCustomization('ohif.aboutModal');
const UserPreferencesModal = customizationService.getCustomization('ohif.userPreferencesModal');
const menuOptions = [
{
@ -479,10 +476,9 @@ function WorkList({
icon: 'info',
onClick: () =>
show({
content: AboutModal,
content: AboutModal as React.ComponentType,
title: t('AboutModal:About OHIF Viewer'),
contentProps: { versionNumber, commitHash },
containerDimensions: 'max-w-4xl max-h-4xl',
containerClassName: 'max-w-md ',
}),
},
{
@ -491,24 +487,8 @@ function WorkList({
onClick: () =>
show({
title: t('UserPreferencesModal:User preferences'),
content: UserPreferences,
contentProps: {
hotkeyDefaults: hotkeysManager.getValidHotkeyDefinitions(hotkeyDefaults),
hotkeyDefinitions,
onCancel: hide,
currentLanguage: currentLanguage(),
availableLanguages,
defaultLanguage,
onSubmit: state => {
if (state.language.value !== currentLanguage().value) {
i18n.changeLanguage(state.language.value);
}
hotkeysManager.setHotkeys(state.hotkeyDefinitions);
hide();
},
onReset: () => hotkeysManager.restoreDefaultBindings(),
hotkeysModule: hotkeys,
},
content: UserPreferencesModal as React.ComponentType,
containerClassName: 'flex max-w-4xl flex-col',
}),
},
];
@ -523,7 +503,6 @@ function WorkList({
});
}
const { customizationService } = servicesManager.services;
const LoadingIndicatorProgress = customizationService.getCustomization(
'ui.loadingIndicatorProgress'
);

View File

@ -70,7 +70,7 @@ function modeFactory({ modeConfiguration }) {
uiModalService,
} = servicesManager.services;
uiDialogService.dismissAll();
uiDialogService.hideAll();
uiModalService.hide();
toolGroupService.destroy();
syncGroupService.destroy();

View File

@ -135,7 +135,7 @@ const bindings = [
},
{
commandName: 'cancelMeasurement',
label: 'Cancel Cornerstone Measurement',
label: 'Cancel Measurement',
keys: ['esc'],
},
{

View File

@ -382,6 +382,10 @@ export default class ExtensionManager extends PubSubService {
return this.dataSourceMap[dataSourceName];
};
getDataSourceInstance = dataSourceName => {
return this.dataSourceMap[dataSourceName][0];
};
getActiveDataSource = () => {
return this.dataSourceMap[this.activeDataSource];
};
@ -409,6 +413,29 @@ export default class ExtensionManager extends PubSubService {
return this.getDataSourceDefinition(this.activeDataSource);
};
/**
* Gets a formatted list of data sources suitable for UI display/selection.
* Only returns data sources that support STOW or have a WADO root.
* @returns Array of data source options with value, label, and placeholder
*/
getDataSourcesForUI = () => {
// If multi-select export is not allowed, return empty list
if (this._appConfig?.allowMultiSelectExport === false) {
return [];
}
return Object.keys(this.dataSourceMap)
.filter(ds => {
const configuration = this.dataSourceDefs[ds]?.configuration;
return configuration?.supportsStow ?? configuration?.wadoRoot;
})
.map(ds => ({
value: ds,
label: ds,
placeHolder: ds,
}));
};
/**
* @private
* @param {string} moduleType

View File

@ -9,7 +9,6 @@ export interface BaseCustomization extends Obj {
description?: string;
label?: string;
commands?: Command[];
content?: (...props: any) => React.JSX.Element;
}
export interface LabelCustomization extends BaseCustomization {
@ -29,6 +28,7 @@ export interface ComponentCustomization extends BaseCustomization {
}
export type Customization =
| React.ComponentType
| BaseCustomization
| LabelCustomization
| CommandCustomization

View File

@ -1,80 +1,95 @@
import { PubSubService } from '../_shared/pubSubServiceInterface';
import type { ManagedDialogProps } from 'platform/ui-next/src/contextProviders/ManagedDialog';
type DialogOptions = ManagedDialogProps;
class UIDialogService extends PubSubService {
public static readonly EVENTS = {};
const name = 'uiDialogService';
public static REGISTRATION = {
name: 'uiDialogService',
const serviceImplementation = {
_show: (options: DialogOptions) => {
console.warn('show() NOT IMPLEMENTED');
return '';
},
_hide: (id: string) => console.warn('hide() NOT IMPLEMENTED'),
_hideAll: () => console.warn('hideAll() NOT IMPLEMENTED'),
_isEmpty: () => {
console.warn('isEmpty() NOT IMPLEMENTED');
return true;
},
_customComponent: null,
};
class UIDialogService {
static REGISTRATION = {
name,
altName: 'UIDialogService',
create: ({ configuration = {} }) => {
create: (): UIDialogService => {
return new UIDialogService();
},
};
serviceImplementation = {
_dismiss: () => console.warn('dismiss() NOT IMPLEMENTED'),
_dismissAll: () => console.warn('dismissAll() NOT IMPLEMENTED'),
_create: () => console.warn('create() NOT IMPLEMENTED'),
};
readonly name = name;
constructor() {
super(UIDialogService.EVENTS);
this.serviceImplementation = {
...this.serviceImplementation,
};
/**
* Show a new UI dialog
*
* @param {DialogOptions} options - The dialog options
* @returns {string} The dialog id
*/
show(options: DialogOptions): string {
return serviceImplementation._show(options);
}
public create({
id,
content,
contentProps,
onStart,
onDrag,
onStop,
centralize = false,
preservePosition = true,
isDraggable = true,
showOverlay = false,
defaultPosition,
onClickOutside,
}) {
return this.serviceImplementation._create({
id,
content,
contentProps,
onStart,
onDrag,
onStop,
centralize,
preservePosition,
isDraggable,
showOverlay,
defaultPosition,
onClickOutside,
});
/**
* Hide a specific dialog by id
*
* @param {string} id - The dialog id to hide
*/
hide(id: string): void {
return serviceImplementation._hide(id);
}
public dismiss({ id }) {
return this.serviceImplementation._dismiss({ id });
/**
* Hide all currently shown dialogs
*/
hideAll(): void {
return serviceImplementation._hideAll();
}
public dismissAll() {
return this.serviceImplementation._dismissAll();
/**
* Check if there are any dialogs currently shown
*
* @returns {boolean} True if no dialogs are shown
*/
isEmpty(): boolean {
return serviceImplementation._isEmpty();
}
public setServiceImplementation({
dismiss: dismissImplementation,
dismissAll: dismissAllImplementation,
create: createImplementation,
}) {
if (dismissImplementation) {
this.serviceImplementation._dismiss = dismissImplementation;
/**
* This provides flexibility in customizing the Modal's default component
*
* @returns {React.Component}
*/
getCustomComponent() {
return serviceImplementation._customComponent;
}
/**
* Set the service implementation
*/
setServiceImplementation({ show, hide, hideAll, isEmpty, customComponent }: any): void {
if (show) {
serviceImplementation._show = show;
}
if (dismissAllImplementation) {
this.serviceImplementation._dismissAll = dismissAllImplementation;
if (hide) {
serviceImplementation._hide = hide;
}
if (createImplementation) {
this.serviceImplementation._create = createImplementation;
if (hideAll) {
serviceImplementation._hideAll = hideAll;
}
if (isEmpty) {
serviceImplementation._isEmpty = isEmpty;
}
if (customComponent) {
serviceImplementation._customComponent = customComponent;
}
}
}

View File

@ -1,16 +1,3 @@
/**
* UI Modal
*
* @typedef {Object} ModalProps
* @property {ReactElement|HTMLElement} [content=null] Modal content.
* @property {Object} [contentProps=null] Modal content props.
* @property {boolean} [shouldCloseOnEsc=false] Modal is dismissible via the esc key.
* @property {boolean} [isOpen=true] Make the Modal visible or hidden.
* @property {boolean} [closeButton=true] Should the modal body render the close button.
* @property {string} [title=null] Should the modal render the title independently of the body content.
* @property {string} [customClassName=null] The custom class to style the modal.
*/
const name = 'uiModalService';
const serviceImplementation = {
@ -38,30 +25,20 @@ class UIModalService {
show({
content = null,
contentProps = null,
shouldCloseOnEsc = true,
isOpen = true,
closeButton = true,
title = null,
customClassName = null,
movable = false,
containerDimensions = null,
contentDimensions = null,
className = null,
shouldCloseOnEsc = true,
shouldCloseOnOverlayClick = true,
shouldCloseImmediately = false,
containerClassName = null,
}) {
return serviceImplementation._show({
content,
contentProps,
shouldCloseOnEsc,
isOpen,
closeButton,
title,
customClassName,
movable,
containerDimensions,
contentDimensions,
className,
shouldCloseOnOverlayClick,
shouldCloseImmediately,
containerClassName,
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

View File

@ -0,0 +1,314 @@
---
title: uiDialogService
---
## DialogService
This guide details the migration steps for the `uiDialogService` API changes, based on the provided diff. The most significant change is a shift from methods like `.create()` and `.dismiss()` to `.show()` and `.hide()`, alongside structural changes in how dialogs are defined and rendered. The changes aim for a more streamlined and flexible dialog management, leveraging React context for state management.
**Key Changes:**
* **`uiDialogService.create()` and `uiDialogService.dismiss()` are deprecated.** They have been replaced with `uiDialogService.show()` and `uiDialogService.hide()`. This change
makes it consistent with the `uiModalService` and `uiNotificationService` APIs.
* **`content` property now expects a React Component Type**, not an instance. Props for the content component are passed via `contentProps`.
* **The `dialogId` is now consistently passed as `id` within the options** to `uiDialogService.show()`.
### Props Kept same as before
| Prop | Description |
|------|-------------|
| `id` | Still required, but we don't return it from the `show` method anymore. |
| `content` | This is now expected to be a *React component type* (a function or class that returns JSX) |
| `contentProps` | This continues to be the way to pass data *to* your custom dialog component. However, several specific props that *used* to be passed here (like `onClose`, `actions`) are no longer valid. |
| `isDraggable` | Controls whether the dialog can be moved by dragging. |
| `defaultPosition` | Allows you to specify an initial `{ x, y }` position for the dialog. |
| `title` | The title text to display in the dialog header. |
| `showOverlay` | default true - if the dialog is draggable the overlay is not shown by default |
### Removed Props:
| Prop | Description |
|------|-------------|
| `centralize` | Dialogs are now centered by default via CSS if you don't want center you pass defaultPosition |
| `preservePosition` | Work in progress and will be available in future |
| `contentDimensions` | Removed - should be specified directly in dialogs |
| `onStart` | Removed |
| `onDrag` | Removed |
| `onStop` | Removed |
| `onClickOutside` | Removed - if you want to close the dialog on click outside, you can use the `shouldCloseOnOverlayClick` prop |
### Renamed Props:
| Prop | Description |
|------|-------------|
| `containerDimensions` | renamed to `containerClassName` |
### New Props:
| Prop | Description |
|------|-------------|
| `unstyled` | A boolean prop to render the dialog without the default styling. It is used for context menu dialogs |
| `shouldCloseOnEsc` | Default off for dialogs - Controls whether pressing the Escape key will close the dialog. |
| `shouldCloseOnOverlayClick` | Default off for dialogs - Controls whether clicking the overlay background will close the dialog. |
**Migration Steps:**
1. **Replace `.create()` with `.show()`:**
```diff
- const dialogId = uiDialogService.create({
- id: 'my-dialog',
- content: MyDialogComponent,
- contentProps: { prop1: 'value1' },
- // ... other options ...
- });
+ uiDialogService.show({
+ id: 'my-dialog',
+ content: MyDialogComponent,
+ contentProps: { prop1: 'value1' },
+ // ... other options ...
+ });
```
2. Rename `containerDimensions` to `containerClassName`
```diff
- containerDimensions: 'w-[70%] max-w-[900px]',
+ containerClassName: 'w-[70%] max-w-[900px]',
```
3. **Replace `.dismiss({ id: dialogId })` with `.hide(dialogId)`:**
```diff
- uiDialogService.dismiss({ id: dialogId });
+ uiDialogService.hide(dialogId);
```
4. **Replace `dismissAll` with `hideAll`**
```diff
- uiDialogService.dismissAll();
+ uiDialogService.hideAll();
```
5. **Update Dialog Content:**
* Ensure your dialog content is defined as a React component (functional or class-based).
* Pass props to the component via `contentProps`.
* You don't need to pass in `onClose` or `hide` as they are now handled passed in automatically.
```javascript
// Example: MyDialogComponent.tsx
function MyDialogComponent({ prop1, hide }) {
return (
<div>
<p>Value of prop1: {prop1}</p>
<button onClick={hide}>Close</button>
</div>
);
}
```
**Example: Updating a Simple Alert Dialog**
```javascript
// Before (using deprecated API)
let dialogId;
const showAlert = (message) => {
dialogId = uiDialogService.create({
centralize: true,
isDraggable: false,
content: Dialog,
contentProps: {
title: 'Alert',
body: () => <p>{message}</p>,
onClose: () => uiDialogService.dismiss({ id: dialogId }),
},
});
};
// After (using new API)
function AlertDialog({ message, hide }) {
return (
<div>
<p>{message}</p>
<button onClick={hide}>OK</button>
</div>
);
}
const showAlert = (message) => {
uiDialogService.show({
id: 'alert-dialog',
title: 'Alert',
content: AlertDialog,
contentProps: { message },
});
};
```
---
## Components
### CreateReportDialogPrompt
**Key Changes:**
* **Function Signature Update:** The function now accepts an object with `servicesManager`, `extensionManager`, `title`(optional).
* **Return Value Structure:** The function now returns an object containing `value` (the report name), `dataSourceName` (the selected data source, if applicable), and `action` (indicating the user's choice).
**Migration Steps:**
1. **Update Function Call:**
Previously, the function was called with separate arguments. You should now pass an object:
```diff
- const promptResult = await createReportDialogPrompt(uiDialogService, {
- extensionManager,
- });
+ const promptResult = await createReportDialogPrompt({
+ servicesManager,
+ extensionManager,
+ title: 'Store Segmentation', // Optional title
+ });
```
### promptSaveReport
Not changed, just a javascript to typescript migration.
### callLabelAutocompleteDialog
`callLabelAutocompleteDialog` is deprecated and has been replaced by `callInputDialogAutoComplete`. This new function simplifies the asynchronous handling of user input by using `uiDialogService.show()` and returning a promise.
### showLabelAnnotationPopup
`showLabelAnnotationPopup` has been replaced with `callInputDialogAutoComplete`. This update also uses `uiDialogService.show()` and promises, and it removes the callback function.
- The function now expects an object with `measurement`, `uiDialogService`, `labelConfig`, and `renderContent`.
```diff
- const value = await showLabelAnnotationPopup(
- measurement,
- servicesManager.services.uiDialogService,
- labelConfig,
- renderContent
- );
+ const value = await callInputDialogAutoComplete({
+ measurement,
+ uiDialogService,
+ labelConfig,
+ renderContent,
+ });
```
### callInputDialog
- expects an objects now and returns the value of the input which you can then use for actions
```diff
- callInputDialog(
- uiDialogService,
- {
- text: '',
- label: `${length}`,
- },
- (value, id) => {
- if (id === 'save') {
- adjustCalibration(Number.parseFloat(value));
- resolve(true);
- } else {
- reject('cancel');
- }
- },
- false,
- {
- dialogTitle: 'Calibration',
- inputLabel: 'Actual Physical distance (mm)',
- validateFunc: val => {
- const v = Number.parseFloat(val);
- return !isNaN(v) && v !== 0.0;
- },
- }
- );
+ callInputDialog({
+ uiDialogService,
+ title: 'Calibration',
+ placeholder: 'Actual Physical distance (mm)',
+ defaultValue: `${length}`,
+ }).then(newValue => {
+ adjustCalibration(Number.parseFloat(newValue));
+ resolve(true);
+ });
```
or another one
```diff
- callInputDialog(
- uiDialogService,
- { text: '', label: 'Enter description' },
- (value, action) => {
- if (action === 'save') {
- saveFunction(value);
- }
- }
- );
+ callInputDialog({
+ uiDialogService,
+ title: 'Enter description of the Series',
+ defaultValue: '',
+ }).then(value => {
+ saveFunction(value);
+ });
```
### colorPickerDialog
Instead of calling `colorPickerDialog(uiDialogService, rgbaColor, callback)`, use `uiDialogService.show()` with `ColorPickerDialog` as the content.
```diff
- colorPickerDialog(uiDialogService, rgbaColor, (newRgbaColor, actionId) => {
- if (actionId === 'cancel') {
- return;
- }
- const color = [newRgbaColor.r, newRgbaColor.g, newRgbaColor.b, newRgbaColor.a * 255.0];
- segmentationService.setSegmentColor(viewportId, segmentationId, segmentIndex, color);
- });
// after
+ uiDialogService.show({
+ content: ColorPickerDialog,
+ title: 'Segment Color',
+ contentProps: {
+ value: rgbaColor,
+ onSave: newRgbaColor => {
+ const color = [newRgbaColor.r, newRgbaColor.g, newRgbaColor.b, newRgbaColor.a * 255.0];
+ segmentationService.setSegmentColor(viewportId, segmentationId, segmentIndex, color);
+ },
+ },
+ });
```

View File

@ -0,0 +1,104 @@
---
title: uiModalService
---
## ModalService
### Props Kept same as before
| Prop | Description |
|------|-------------|
| `content` | This is now expected to be a *React component type* (a function or class that returns JSX) |
| `contentProps` | This continues to be the way to pass data *to* your custom dialog component. However, several specific props that *used* to be passed here (like `onClose`, `actions`) are no longer valid. |
| `title` | The title text to display in the dialog header. |
| `shouldCloseOnEsc` | Allows closing the modal when the escape key is pressed. |
| `shouldCloseOnOverlayClick` | Allows closing the modal when the overlay is clicked. |
### Renamed Props:
| Prop | Description |
|------|-------------|
| `containerDimensions` | renamed to `containerClassName` |
### Removed Props:
| Prop | Description |
|------|-------------|
| `movable` | It's removed because modals shouldn't be movable. If you need to move a dialog, use `uidDialogService` and `dialogs` instead. |
| `isOpen` | always assumed `true` when `show` is called. |
| `contentDimensions` | Removed, it is now component's responsibility to set the size for the content |
| `customClassName` | renamed to `className` |
| `closeButton` | The component now manages modal closing internally. If you need a close button, you can add one, perhaps by checking out the `FooterActions` component. |
**Migration Steps:**
### Rename of `containerDimensions` to `containerClassName` and removal of `contentDimensions`
Before
```js
uiModalService.show({
title: 'Download High-Quality Image',
content: CornerstoneViewportDownloadForm,
contentProps: {
activeViewportId,
},
containerDimensions: 'w-[70%] max-w-[900px]',
contentDimensions: 'h-[493px] w-[460px] pl-[12px] pr-[12px]',
});
```
After: the component is responsible for setting the size
```js
function CornerstoneViewportDownloadForm({ activeViewportId }) {
return (
<div className="h-[493px] w-[460px] pl-[12px] pr-[12px]">
<h2 className="text-lg font-bold">Download Image</h2>
<p>Viewport ID: {activeViewportId}</p>
<button className="mt-4 bg-blue-500 text-white p-2 rounded">Download</button>
</div>
);
}
// Show the modal
uiModalService.show({
title: 'Download High-Quality Image',
content: CornerstoneViewportDownloadForm,
contentProps: { activeViewportId },
containerClassName: 'w-[70%] max-w-[900px]',
});
```
### onClose
Previously, you had to pass in the `onClose` as `hide` function automatically added to the component.
```diff
- uiModalService.show({
- title: 'Untrack Series',
- content: UntrackSeriesModal,
- contentProps: { onConfirm },
- onClose: () => uiModalService.hide(),
- });
+ uiModalService.show({
+ title: 'Untrack Series',
+ content: UntrackSeriesModal,
+ contentProps: {
+ onConfirm,
+ hide, // passed in automatically in the background
+ },
+ });
```

View File

@ -16,6 +16,7 @@ import loadingIndicatorProgress from '../../../assets/img/loading-indicator-icon
import loadingIndicatorPercent from '../../../assets/img/loading-indicator-percent.png';
import viewportActionCorners from '../../../assets/img/viewport-action-corners.png';
import contextMenu from '../../../assets/img/context-menu.jpg';
import viewportDownloadWarning from '../../../assets/img/viewport-download-warning.png';
import segmentationOverlay from '../../../assets/img/segmentation-overlay.png';
import segDisplayEditingTrue from '../../../assets/img/segDisplayEditingTrue.png';
@ -24,6 +25,8 @@ import thumbnailMenuItemsImage from '../../../assets/img/thumbnailMenuItemsImage
import studyMenuItemsImage from '../../../assets/img/studyMenuItemsImage.png';
import windowLevelActionMenu from '../../../assets/img/windowLevelActionMenu.png';
import viewPortNotificationImage from '../../../assets/img/viewport-notification.png';
import captureViewportModal from '../../../assets/img/captureViewportModal.png';
import aboutModal from '../../../assets/img/aboutModal.png';
export const viewportOverlayCustomizations = [
{
@ -269,24 +272,6 @@ window.config = {
default: 'The CinePlayer component in the UI',
configuration: null,
},
{
id: 'cornerstone.windowLevelActionMenu',
description: 'Window level action menu for the cornerstone viewport.',
image: windowLevelActionMenu,
default: null,
configuration: `
window.config = {
// rest of window config
customizationService: [
{
'cornerstone.windowLevelActionMenu': {
$set: CustomizedComponent,
},
},
],
};
`,
},
{
id: 'cornerstone.windowLevelPresets',
description: 'Window level presets for the cornerstone viewport.',
@ -785,10 +770,142 @@ window.config = {
};
`,
},
{
id: 'viewportDownload.warningMessage',
description: 'Customizes the warning message for the viewport download form.',
image: viewportDownloadWarning,
default: {
enabled: true,
value: 'Not For Diagnostic Use',
},
configuration: `
window.config = {
// rest of window config
customizationService: [
{
'viewportDownload.warningMessage': {
$set: {
enabled: true,
value: 'Careful! This is not for diagnostic use.',
},
},
},
],
};
`,
},
{
id: 'ohif.captureViewportModal',
description: 'The modal for capturing the viewport image.',
image: captureViewportModal,
default: 'Our own default component',
configuration: `
window.config = {
// rest of window config
// You can use the component from ImageModal and FooterAction
// to build your own custom component
customizationService: [
{
'ohif.captureViewportModal': {
$set: CustomizedComponent,
},
},
],
};
`,
},
{
id: 'ohif.aboutModal',
description: 'The About modal',
image: aboutModal,
default: 'Our own default component',
configuration: `
window.config = {
// rest of window config
// You can use the component from AboutModal
// to build your own custom component
customizationService: [
{
'ohif.aboutModal': {
$set: CustomizedComponent,
},
},
],
};
`,
},
{
id: 'viewportDownload.warningMessage',
description: 'Customizes the warning message for the viewport download form.',
image: viewportDownloadWarning,
default: {
enabled: true,
value: 'Not For Diagnostic Use',
},
configuration: `
window.config = {
// rest of window config
customizationService: [
{
'viewportDownload.warningMessage': {
$set: {
enabled: true,
value: 'Careful! This is not for diagnostic use.',
},
},
},
],
};
`,
},
{
id: 'ohif.captureViewportModal',
description: 'The modal for capturing the viewport image.',
image: captureViewportModal,
default: 'Our own default component',
configuration: `
window.config = {
// rest of window config
// You can use the component from ImageModal and FooterAction
// to build your own custom component
customizationService: [
{
'ohif.captureViewportModal': {
$set: CustomizedComponent,
},
},
],
};
`,
},
{
id: 'ohif.aboutModal',
description: 'The About modal',
image: aboutModal,
default: 'Our own default component',
configuration: `
window.config = {
// rest of window config
// You can use the component from AboutModal
// to build your own custom component
customizationService: [
{
'ohif.aboutModal': {
$set: CustomizedComponent,
},
},
],
};
`,
},
{
id: 'viewportActionMenu.windowLevelActionMenu',
description:
'Configures the display and location of the window level action menu in the viewport.',
'Configures the display and location of the window level action menu in the viewport.',
image: windowLevelActionMenu,
default: null,
configuration: `
@ -797,9 +914,8 @@ window.config = {
customizationService: [
{
'viewportActionMenu.windowLevelActionMenu': {
$set: {
enabled: true,
location: 1, // Set the location of the menu in the viewport.
$merge: {
location: 0, // Set the location of the menu in the viewport.
// 0: topLeft
// 1: topRight
// 2: bottomLeft
@ -822,7 +938,7 @@ window.config = {
customizationService: [
{
'viewportActionMenu.segmentationOverlay': {
$set: {
$merge: {
enabled: true,
location: 1, // Set the location of the overlay in the viewport.
// 0: topLeft

View File

@ -136,6 +136,7 @@ module.exports = {
prism: {
theme: require('prism-react-renderer').themes.github,
darkTheme: require('prism-react-renderer').themes.dracula,
additionalLanguages: ['diff'],
},
algolia: {
appId: 'EFLT6YIHHZ',

View File

@ -233,11 +233,6 @@ input[type='number'] {
-moz-appearance: textfield; /* For Firefox */
}
.navbar__item {
display: flex;
align-items: center;
}
.navbar__item svg {
margin-right: 5px;
display: inline-block;
@ -642,6 +637,7 @@ html[data-theme='dark'] .markdown hr {
/* Markdown code block styling */
.markdown pre {
font-size: 0.9rem;
margin-top: 1em;
}
.theme-code-block {
@ -725,3 +721,7 @@ a.dropdown__link[href='/3.9/migration-guide/3p8-to-3p9/']::after {
font-size: 0.9em;
margin-left: 4px;
}
li:first-child {
margin-top: 0.5em; /* Adjust '1em' to your desired spacing */
}

View File

@ -98,8 +98,7 @@ function initI18n(
},
},
react: {
useSuspense: false, // TODO: Was seeing weird errors without this
wait: true,
useSuspense: true,
bindI18n: 'languageChanged editorSaved',
},
});
@ -123,7 +122,7 @@ function initI18n(
},
detection,
react: {
wait: true,
useSuspense: true,
},
});
}

View File

@ -190,7 +190,8 @@ const DataRow: React.FC<DataRowProps> = ({
return (
<div
ref={rowRef}
className={`flex flex-col ${isVisible ? '' : 'opacity-60'}`}>
className={`flex flex-col ${isVisible ? '' : 'opacity-60'}`}
>
<div
className={`flex items-center ${
isSelected ? 'bg-popover' : 'bg-muted'
@ -291,7 +292,11 @@ const DataRow: React.FC<DataRowProps> = ({
<Icons.More className="h-6 w-6" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent
align="end"
// this was causing issue for auto focus on input dialog
onCloseAutoFocus={e => e.preventDefault()}
>
<>
<DropdownMenuItem onClick={e => handleAction('Rename', e)}>
<Icons.Rename className="text-foreground" />

View File

@ -3,8 +3,41 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Cross2Icon } from '@radix-ui/react-icons';
import { cn } from '../../lib/utils';
import { useDraggable } from './useDraggable';
const Dialog = DialogPrimitive.Root;
interface DialogContextValue {
isDraggable?: boolean;
shouldCloseOnEsc?: boolean;
shouldCloseOnOverlayClick?: boolean;
showOverlay?: boolean;
}
const DialogContext = React.createContext<DialogContextValue>({
isDraggable: false,
shouldCloseOnEsc: true,
shouldCloseOnOverlayClick: true,
});
interface DialogRootProps extends DialogPrimitive.DialogProps {
isDraggable?: boolean;
shouldCloseOnEsc?: boolean;
shouldCloseOnOverlayClick?: boolean;
showOverlay?: boolean;
}
const Dialog = ({
isDraggable,
shouldCloseOnEsc = true,
shouldCloseOnOverlayClick = true,
showOverlay = true,
...props
}: DialogRootProps) => (
<DialogContext.Provider
value={{ isDraggable, shouldCloseOnEsc, shouldCloseOnOverlayClick, showOverlay }}
>
<DialogPrimitive.Root {...props} />
</DialogContext.Provider>
);
const DialogTrigger = DialogPrimitive.Trigger;
@ -14,12 +47,14 @@ const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
className?: string;
}
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
<div
ref={ref}
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-40 bg-black/60',
className
)}
{...props}
@ -27,36 +62,83 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
DialogContentProps & {
children?: React.ReactNode;
}
>(({ className, children, ...props }, ref) => {
const { isDraggable, shouldCloseOnEsc, shouldCloseOnOverlayClick, showOverlay } =
React.useContext(DialogContext);
const { handlePointerDown, setRefs, initialTransform } = useDraggable(
{
enabled: isDraggable,
},
ref
);
// When not isDraggable, Tailwind centers the dialog.
// When isDraggable, we remove the builtin centering so our inline transform takes over.
const contentClassName = cn(
'max-w-md w-full bg-muted data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid gap-4 p-4 shadow-lg duration-200 sm:rounded-lg',
!isDraggable ? 'translate-x-[-50%] translate-y-[-50%]' : '',
className
);
const style = isDraggable ? { ...props.style, transform: initialTransform } : props.style;
const content = (
<DialogPrimitive.Content
ref={ref}
ref={setRefs}
className={contentClassName}
{...props}
style={style}
onPointerDown={isDraggable ? handlePointerDown : props.onPointerDown}
onEscapeKeyDown={event => {
if (!shouldCloseOnEsc) {
event.preventDefault();
}
}}
onInteractOutside={event => {
if (!shouldCloseOnOverlayClick) {
event.preventDefault();
}
}}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<Cross2Icon className="text-primary h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
);
return (
<DialogPortal>
{showOverlay && !isDraggable && <DialogOverlay />}
{content}
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
'drag-handle relative flex select-none flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
{...props}
/>
);
{props.children}
</div>
);
};
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
@ -69,11 +151,13 @@ DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> & {
className?: string;
}
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-xl font-semibold leading-none tracking-tight', className)}
className={cn('text-primary-light text-xl font-normal leading-none tracking-tight', className)}
{...props}
/>
));
@ -81,11 +165,13 @@ DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> & {
className?: string;
}
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-muted-foreground text-base', className)}
className={cn('text-base', className)}
{...props}
/>
));

View File

@ -0,0 +1,108 @@
import * as React from 'react';
interface Offset {
x: number;
y: number;
}
interface DragState {
startX: number;
startY: number;
initialOffset: Offset;
}
interface UseDraggableProps {
enabled?: boolean;
}
interface UseDraggableReturn {
offset: React.RefObject<Offset>;
internalRef: React.RefObject<HTMLDivElement>;
handlePointerDown: (e: React.PointerEvent<HTMLDivElement>) => void;
setRefs: (node: HTMLDivElement) => void;
initialTransform: string | undefined;
}
export function useDraggable(
props: UseDraggableProps,
ref?: React.ForwardedRef<HTMLDivElement>
): UseDraggableReturn {
const { enabled = false } = props;
const offsetRef = React.useRef<Offset>({ x: 0, y: 0 });
const internalRef = React.useRef<HTMLDivElement>(null);
const dragState = React.useRef<DragState | null>(null);
const handlePointerMove = React.useCallback((e: PointerEvent) => {
if (!dragState.current) {
return;
}
const deltaX = e.clientX - dragState.current.startX;
const deltaY = e.clientY - dragState.current.startY;
const newOffset = {
x: dragState.current.initialOffset.x + deltaX,
y: dragState.current.initialOffset.y + deltaY,
};
offsetRef.current = newOffset;
if (internalRef.current) {
internalRef.current.style.transform = `translate(-50%, -50%) translate(${newOffset.x}px, ${newOffset.y}px)`;
}
}, []);
const handlePointerUp = React.useCallback(() => {
if (internalRef.current) {
internalRef.current.style.transition = '';
}
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
dragState.current = null;
}, [handlePointerMove]);
const handlePointerDown = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (!target.closest('.drag-handle')) {
return;
}
if (internalRef.current) {
internalRef.current.style.transition = 'none';
}
dragState.current = {
startX: e.clientX,
startY: e.clientY,
initialOffset: { ...offsetRef.current },
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
},
[handlePointerMove, handlePointerUp]
);
const setRefs = React.useCallback(
(node: HTMLDivElement) => {
internalRef.current = node;
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
}
},
[ref]
);
const initialTransform = enabled
? `translate(-50%, -50%) translate(${offsetRef.current.x}px, ${offsetRef.current.y}px)`
: undefined;
return {
offset: offsetRef,
internalRef,
handlePointerDown,
setRefs,
initialTransform,
};
}

View File

@ -62,6 +62,11 @@ const DropdownMenuContent = React.forwardRef<
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
onKeyDown={e => {
// Todo: or maybe we just want to prevent arrows?
e.stopPropagation();
props.onKeyDown?.(e);
}}
className={cn(
'bg-popover text-popover-foreground border-input z-50 min-w-[8rem] overflow-hidden rounded border p-1 shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',

View File

@ -0,0 +1,102 @@
import React from 'react';
import { Button } from '../Button/Button';
import { cn } from '../../lib/utils';
interface FooterActionProps {
children: React.ReactNode;
className?: string;
}
interface ActionProps extends FooterActionProps {
onClick: () => void;
className?: string;
}
type FooterActionComponent = React.FC<FooterActionProps> & {
Left: React.FC<FooterActionProps>;
Right: React.FC<FooterActionProps>;
Primary: React.FC<ActionProps>;
Secondary: React.FC<ActionProps>;
Auxiliary: React.FC<ActionProps>;
};
export const FooterAction: FooterActionComponent = ({ children, className }: FooterActionProps) => {
// Convert children to array for easier inspection
const arrayChildren = React.Children.toArray(children);
// Check if we have a <FooterAction.Left> or <FooterAction.Right> among children
const hasLeft = arrayChildren.some(
(child: any) => child.type?.displayName === 'FooterAction.Left'
);
const hasRight = arrayChildren.some(
(child: any) => child.type?.displayName === 'FooterAction.Right'
);
// Decide on the justification class based on presence of Left/Right
let justifyClass = 'justify-between'; // default
if (hasLeft && !hasRight) {
justifyClass = 'justify-start';
} else if (!hasLeft && hasRight) {
justifyClass = 'justify-end';
}
// If both or neither are present, keep justify-between (or adjust if you like)
return (
<div className={cn('flex w-full flex-shrink-0 items-center', justifyClass, className)}>
{children}
</div>
);
};
FooterAction.displayName = 'FooterAction';
FooterAction.Left = ({ children }: FooterActionProps) => {
return <div className="flex items-center">{children}</div>;
};
FooterAction.Left.displayName = 'FooterAction.Left';
FooterAction.Right = ({ children }: FooterActionProps) => {
return <div className="flex items-center space-x-2">{children}</div>;
};
FooterAction.Right.displayName = 'FooterAction.Right';
// Primary action: Solid button (default)
FooterAction.Primary = ({ children, onClick, className = 'min-w-[80px]' }: ActionProps) => {
return (
<Button
variant="default"
onClick={onClick}
className={className}
>
{children}
</Button>
);
};
FooterAction.Primary.displayName = 'FooterAction.Primary';
// Secondary action: Ghost button
FooterAction.Secondary = ({ children, onClick, className = 'min-w-[80px]' }: ActionProps) => {
return (
<Button
variant="secondary"
onClick={onClick}
className={className}
>
{children}
</Button>
);
};
FooterAction.Secondary.displayName = 'FooterAction.Secondary';
// Tertiary action: Ghost button with different styling
FooterAction.Auxiliary = ({ children, onClick, className }: ActionProps) => {
return (
<Button
variant="ghost"
onClick={onClick}
className={className}
>
{children}
</Button>
);
};
FooterAction.Auxiliary.displayName = 'FooterAction.Auxiliary';

View File

@ -0,0 +1,3 @@
import { FooterAction } from './FooterAction';
export { FooterAction };

View File

@ -46,6 +46,7 @@ import Show from './Sources/Show';
import SidePanelCloseLeft from './Sources/SidePanelCloseLeft';
import SidePanelCloseRight from './Sources/SidePanelCloseRight';
import SortingAscending from './Sources/SortingAscending';
import SocialGithub from './Sources/SocialGithub';
import SortingDescending from './Sources/SortingDescending';
import StatusError from './Sources/StatusError';
import StatusSuccess from './Sources/StatusSuccess';
@ -471,6 +472,7 @@ export const Icons = {
Show,
SidePanelCloseLeft,
SidePanelCloseRight,
SocialGithub,
SortingAscending,
SortingDescending,
Sorting,

View File

@ -0,0 +1,39 @@
import React from 'react';
import type { IconProps } from '../types';
export const SocialGithub = (props: IconProps) => (
<svg
width="24px"
height="24px"
viewBox="0 0 24 24"
version="1.1"
>
<g
id="SocialGithub"
stroke="none"
strokeWidth="1"
fill="none"
fillRule="evenodd"
>
<rect
id="Rectangle"
x="0"
y="0"
width="24"
height="24"
></rect>
<g
id="github-mark"
transform="translate(1, 1)"
fill="#FFFFFF"
>
<path
d="M11.009102,0 C4.92135296,0 0,5.04164559 0,11.2788487 C0,16.2645778 3.15328046,20.4848935 7.52771627,21.9785956 C8.07463342,22.0908868 8.2749669,21.7359091 8.2749669,21.4373062 C8.2749669,21.1758281 8.25693914,20.279561 8.25693914,19.3457108 C5.19447352,20.018083 4.55674153,18.0011956 4.55674153,18.0011956 C4.0645837,16.6940344 3.33536083,16.3580774 3.33536083,16.3580774 C2.33301741,15.6671428 3.40837326,15.6671428 3.40837326,15.6671428 C4.52023532,15.7418508 5.10365868,16.824888 5.10365868,16.824888 C6.08774899,18.5427141 7.67351578,18.0573412 8.31147311,17.7585091 C8.4025133,17.0302205 8.69433765,16.5260559 9.00418977,16.2460154 C6.56165372,15.9845373 3.99179662,15.0135622 3.99179662,10.6811845 C3.99179662,9.44873132 4.42896978,8.44040221 5.12168644,7.65619715 C5.01239315,7.37615666 4.62952861,6.21818233 5.23120508,4.66833465 C5.23120508,4.66833465 6.16076142,4.36950256 8.25671379,5.8260798 C9.15406554,5.57919102 10.0794899,5.45359714 11.009102,5.4525397 C11.9386583,5.4525397 12.8862424,5.58339332 13.7612648,5.8260798 C15.8574425,4.36950256 16.7869988,4.66833465 16.7869988,4.66833465 C17.3886753,6.21818233 17.0055854,7.37615666 16.8962921,7.65619715 C17.6072619,8.44040221 18.0264073,9.44873132 18.0264073,10.6811845 C18.0264073,15.0135622 15.4565502,15.9657457 12.995761,16.2460154 C13.3968787,16.6007639 13.7430117,17.2729069 13.7430117,18.3373817 C13.7430117,19.8498753 13.7249839,21.0637661 13.7249839,21.437077 C13.7249839,21.7359091 13.9255427,22.0908868 14.4722345,21.9788248 C18.8466703,20.4846643 22,16.2645778 22,11.2788487 C22.0179786,5.04164559 17.0785978,0 11.009102,0 Z"
id="Path"
></path>
</g>
</g>
</svg>
);
export default SocialGithub;

View File

@ -0,0 +1,44 @@
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../Dialog';
import { cn } from '../../lib/utils';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
contentClassName?: string;
shouldCloseOnEsc?: boolean;
shouldCloseOnOverlayClick?: boolean;
containerClassName?: string;
}
const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
shouldCloseOnEsc = true,
shouldCloseOnOverlayClick = true,
containerClassName,
}) => {
return (
<Dialog
open={isOpen}
onOpenChange={open => !open && onClose()}
shouldCloseOnEsc={shouldCloseOnEsc}
shouldCloseOnOverlayClick={shouldCloseOnOverlayClick}
>
<DialogContent className={containerClassName}>
{title && (
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
)}
<div className={cn('mt-2')}>{children}</div>
</DialogContent>
</Dialog>
);
};
export default Modal;

View File

@ -0,0 +1,258 @@
import React, { createContext, useContext, useRef, useEffect } from 'react';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import { Label, Input as InputComponent, FooterAction } from '../../components';
import { cn } from '../../lib/utils';
interface InputDialogContextValue {
value: string;
setValue: (value: string) => void;
submitOnEnter?: boolean;
}
const InputDialogContext = createContext<InputDialogContextValue | null>(null);
export type InputDialogRootProps = {
/** The controlled value of the input */
value?: string;
/** The default value for uncontrolled usage */
defaultValue?: string;
/** Callback when input value changes */
onChange?: (value: string) => void;
/** Optional className for the root container */
className?: string;
/** Enable save on Enter key press */
submitOnEnter?: boolean;
children: React.ReactNode;
};
const InputDialogRoot = React.forwardRef<HTMLDivElement, InputDialogRootProps>(
({ value, defaultValue = '', onChange, className, submitOnEnter, children }, ref) => {
const [internalValue, setInternalValue] = useControllableState({
prop: value,
defaultProp: defaultValue,
onChange,
});
return (
<InputDialogContext.Provider
value={{
value: internalValue,
setValue: setInternalValue,
submitOnEnter,
}}
>
<div
ref={ref}
className={cn('flex flex-col', className)}
>
{children}
</div>
</InputDialogContext.Provider>
);
}
);
InputDialogRoot.displayName = 'InputDialog';
export interface InputDialogFieldProps extends React.HTMLAttributes<HTMLDivElement> {
/** Optional className for the field container */
className?: string;
children: React.ReactNode;
}
const Field = React.forwardRef<HTMLDivElement, InputDialogFieldProps>(
({ className, children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn('mb-4 flex flex-col space-y-2', className)}
{...props}
>
{children}
</div>
);
}
);
Field.displayName = 'InputDialog.Field';
export interface InputDialogInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
/** ID for the input field */
id?: string;
/** Optional className for the input container */
className?: string;
/** Save handler */
onSave?: (value: string) => void;
/** Placeholder text for the input field */
placeholder?: string;
}
const InputDialogInput = React.forwardRef<HTMLInputElement, InputDialogInputProps>(
({ id = 'dialog-input', className, onSave, ...props }, ref) => {
const context = useContext(InputDialogContext);
if (!context) {
throw new Error('InputDialog.Input must be used within an InputDialog');
}
const { value, setValue } = context;
const inputRef = useRef<HTMLInputElement>(null);
// Combine the forwarded ref with our local ref
React.useImperativeHandle(ref, () => inputRef.current);
// Focus the input when it mounts
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (context.submitOnEnter && e.key === 'Enter') {
e.preventDefault();
const saveButton = document.querySelector(
'[data-cy="input-dialog-save-button"]'
) as HTMLButtonElement;
if (saveButton) {
saveButton.click();
}
}
};
return (
<div className={cn('w-full', className)}>
<InputComponent
ref={inputRef}
id={id}
value={value}
onChange={e => setValue(e.target.value)}
onKeyDown={handleKeyDown}
{...props}
/>
</div>
);
}
);
InputDialogInput.displayName = 'InputDialog.Input';
export interface InputDialogLabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
/** Optional className for the label */
className?: string;
/** For attribute to match input ID */
htmlFor?: string;
children: React.ReactNode;
}
const InputDialogLabel = React.forwardRef<HTMLLabelElement, InputDialogLabelProps>(
({ className, htmlFor = 'dialog-input', children, ...props }, ref) => {
return (
<Label
ref={ref}
className={cn(className)}
htmlFor={htmlFor}
{...props}
>
{children}
</Label>
);
}
);
InputDialogLabel.displayName = 'InputDialog.Label';
export interface InputDialogActionsProps extends React.HTMLAttributes<HTMLDivElement> {
/** Optional className for the actions container */
className?: string;
children: React.ReactNode;
}
const Actions = React.forwardRef<HTMLDivElement, InputDialogActionsProps>(
({ className, children, ...props }, ref) => {
return (
<div
ref={ref}
{...props}
>
<FooterAction className={cn(className)}>
<FooterAction.Right>{children}</FooterAction.Right>
</FooterAction>
</div>
);
}
);
Actions.displayName = 'InputDialog.Actions';
export interface InputDialogActionButtonProps {
/** Optional className for the button */
className?: string;
/** Click handler that receives the current input value */
onClick: (value: string) => void;
children: React.ReactNode;
}
const ActionsSecondary = React.forwardRef<HTMLDivElement, InputDialogActionButtonProps>(
({ className, onClick, children, ...props }, ref) => {
const context = useContext(InputDialogContext);
if (!context) {
throw new Error('InputDialog.ActionsSecondary must be used within an InputDialog');
}
const { value } = context;
return (
<div
ref={ref}
{...props}
>
<FooterAction.Secondary
onClick={() => onClick(value)}
className={cn(className)}
>
{children}
</FooterAction.Secondary>
</div>
);
}
);
ActionsSecondary.displayName = 'InputDialog.ActionsSecondary';
const ActionsPrimary = React.forwardRef<HTMLDivElement, InputDialogActionButtonProps>(
({ className, onClick, children, ...props }, ref) => {
const context = useContext(InputDialogContext);
if (!context) {
throw new Error('InputDialog.ActionsPrimary must be used within an InputDialog');
}
const { value } = context;
return (
<div
ref={ref}
{...props}
data-cy="input-dialog-save-button"
onClick={() => onClick(value)}
>
<FooterAction.Primary
onClick={() => onClick(value)}
className={cn(className)}
>
{children}
</FooterAction.Primary>
</div>
);
}
);
ActionsPrimary.displayName = 'InputDialog.ActionsPrimary';
export const InputDialog = Object.assign(InputDialogRoot, {
Label: InputDialogLabel,
Input: InputDialogInput,
Field,
Actions,
ActionsSecondary,
ActionsPrimary,
});

View File

@ -0,0 +1,106 @@
import * as React from 'react';
import { Input } from '../Input';
import { ScrollArea } from '../ScrollArea';
import { cn } from '../../lib/utils';
interface PresetDialogProps {
children: React.ReactNode;
className?: string;
}
export function PresetDialog({ children, className }: PresetDialogProps) {
return <div className={cn('flex h-[500px] max-w-lg flex-col', className)}>{children}</div>;
}
/**
* Subcomponent: PresetBody
* A dark "box" container that wraps the filter and grid area.
* Adjust bg color, padding, border, etc. to match your design.
*/
interface PresetBodyProps {
children: React.ReactNode;
className?: string;
}
function PresetBody({ children, className }: PresetBodyProps) {
return (
<div
className={cn(
// Adjust these classes for your desired look
'flex min-h-0 flex-1 flex-col rounded-md border border-white/10 bg-black p-2',
className
)}
>
{children}
</div>
);
}
/**
* Subcomponent: PresetFilter
*/
interface PresetFilterProps {
children: React.ReactNode;
className?: string;
}
function PresetFilter({ children, className }: PresetFilterProps) {
return (
<div className={cn('mb-2 flex w-full flex-shrink-0 items-center space-x-2', className)}>
{children}
</div>
);
}
/**
* Subcomponent: PresetSearch
*/
interface PresetSearchProps extends React.InputHTMLAttributes<HTMLInputElement> {
className?: string;
}
function PresetSearch({ className, ...props }: PresetSearchProps) {
return (
<Input
className={cn('w-full', className)}
{...props}
/>
);
}
/**
* Subcomponent: PresetGrid
*/
interface PresetGridProps {
children: React.ReactNode;
maxHeight?: string;
className?: string;
}
function PresetGrid({ children, maxHeight = 'flex-1', className }: PresetGridProps) {
return (
<ScrollArea className={cn('min-h-0 flex-1', className)}>
<div className="grid grid-cols-4 gap-2 pr-2">{children}</div>
</ScrollArea>
);
}
/**
* Subcomponent: PresetOption
*/
interface PresetOptionProps {
label?: string;
className?: string;
}
function PresetOption({ label = 'Label', className }: PresetOptionProps) {
return (
<div className={cn('flex flex-col items-start space-y-1', className)}>
{/* Default dark placeholder box (swap in an <img> if you like) */}
<div className="bg-popover h-16 w-24 rounded" />
<div className="text-muted-foreground text-left text-base">{label}</div>
</div>
);
}
/* Attach subcomponents to PresetDialog as static properties */
PresetDialog.PresetBody = PresetBody;
PresetDialog.PresetFilter = PresetFilter;
PresetDialog.PresetSearch = PresetSearch;
PresetDialog.PresetGrid = PresetGrid;
PresetDialog.PresetOption = PresetOption;

View File

@ -0,0 +1,2 @@
export { InputDialog } from './InputDialog';
export { PresetDialog } from './PresetDialog';

View File

@ -0,0 +1,114 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
import { Button } from '../Button';
import { Icons } from '../Icons';
interface AboutModalProps {
children: React.ReactNode;
className?: string;
}
export function AboutModal({ children, className }: AboutModalProps) {
return <div className={cn('space-y-1 text-center', className)}>{children}</div>;
}
/** Sub component: Product Name */
interface ProductNameProps {
children: React.ReactNode;
className?: string;
}
function ProductName({ children, className }: ProductNameProps) {
return (
<div className={cn('text-foreground pt-3 text-2xl font-medium leading-none', className)}>
{children}
</div>
);
}
/** Sub-component: Product Version */
interface ProductVersionProps {
children: React.ReactNode;
className?: string;
}
function ProductVersion({ children, className }: ProductVersionProps) {
return (
<div className={cn('text-muted-foreground text-2xl font-light leading-none', className)}>
{children}
</div>
);
}
/** Sub-component: Product Beta */
interface ProductBetaProps {
children: React.ReactNode;
className?: string;
}
function ProductBeta({ children, className }: ProductBetaProps) {
return (
<div className={cn('text-muted-foreground text-xl font-light', className)}>{children}</div>
);
}
/** Sub-component: Body (wraps all detail items) */
interface BodyProps {
children: React.ReactNode;
className?: string;
}
function Body({ children, className }: BodyProps) {
return (
<div className={cn('my-3 flex flex-col items-center space-y-0', className)}>{children}</div>
);
}
/** Sub-component: Detail Item */
interface DetailItemProps {
label: string;
value: string;
className?: string;
}
function DetailItem({ label, value, className }: DetailItemProps) {
return (
<div className={cn('flex flex-col items-center', className)}>
<div className="text-muted-foreground pt-2 text-sm font-semibold tracking-wide">{label}</div>
<div className="text-muted-foreground text-sm">{value}</div>
</div>
);
}
/** Sub-component: Social Item */
interface SocialItemProps {
icon: string;
url: string;
text: string;
className?: string;
}
function SocialItem({ icon, url, text, className }: SocialItemProps) {
return (
<div className={cn('text-foreground flex items-center', className)}>
<div className="inline-block">
<Icons.ByName name={icon} />
</div>
<Button
asChild
variant="link"
className="py-6 text-lg"
>
<a
href={`https://github.com/${url}`}
target="_blank"
rel="noopener noreferrer"
>
{text}
</a>
</Button>
</div>
);
}
/** Attach sub-components to AboutModal as static properties */
AboutModal.ProductName = ProductName;
AboutModal.ProductVersion = ProductVersion;
AboutModal.ProductBeta = ProductBeta;
AboutModal.Body = Body;
AboutModal.DetailItem = DetailItem;
AboutModal.SocialItem = SocialItem;

View File

@ -0,0 +1,231 @@
import * as React from 'react';
import { Input } from '../Input/Input';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '../Select/Select';
import { Switch } from '../Switch/Switch';
import { cn } from '../../lib/utils';
interface ImageModalProps {
children: React.ReactNode;
className?: string;
}
/**
* Main ImageModal container. By default, we do not force any
* layout here. We'll use a "Body" subcomponent for the main area
* that sets up a flex row with a 70/30 split.
*/
export function ImageModal({ children, className }: ImageModalProps) {
return <div className={cn(className)}>{children}</div>;
}
/* -------------------------------------------------------------------------- */
/* Body subcomponent */
interface ImageBodyProps {
children: React.ReactNode;
className?: string;
}
function ImageBody({ children, className }: ImageBodyProps) {
return <div className={cn('flex flex-col sm:flex-row', className)}>{children}</div>;
}
/* -------------------------------------------------------------------------- */
/* ImageVisual subcomponent */
interface ImageVisualProps {
className?: string;
children: React.ReactNode;
}
function ImageVisual({ children, className }: ImageVisualProps) {
return (
<div
className={cn(
'flex-1 items-center justify-center rounded-2xl bg-black/80 p-4 sm:flex-[7]',
'flex', // ensure the container is a flex box
className
)}
>
<div className="h-[512px] w-[512px] overflow-auto">{children}</div>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* ImageOptions subcomponent */
interface ImageOptionsProps {
children: React.ReactNode;
className?: string;
}
function ImageOptions({ children, className }: ImageOptionsProps) {
return <div className={cn('flex-1 space-y-5 p-4 sm:flex-[3]', className)}>{children}</div>;
}
/* -------------------------------------------------------------------------- */
/* Filename subcomponent */
interface FilenameProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'children'> {
children: React.ReactNode;
className?: string;
/** Handler is optional. If not provided, default to a noop. */
onChange?: React.ChangeEventHandler<HTMLInputElement>;
}
function Filename({ children, className, value, onChange, ...props }: FilenameProps) {
return (
<div className={cn('text-foreground space-y-1', className)}>
<label className="block text-base">{children}</label>
<Input
{...props}
className={cn('w-full', className)}
value={value}
onChange={onChange ?? (() => {})}
/>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Filetype subcomponent */
interface FiletypeProps {
selected: string;
/** Handler is optional. If not provided, we do nothing. */
onSelect?: (val: string) => void;
className?: string;
/** Array of file type options */
options?: Array<{ value: string; label: string }>;
}
function Filetype({ selected, onSelect, className, options = [] }: FiletypeProps) {
const defaultOptions = [
{ value: 'jpg', label: 'JPG' },
{ value: 'png', label: 'PNG' },
];
const fileTypeOptions = options.length ? options : defaultOptions;
return (
<Select
value={selected}
onValueChange={val => onSelect?.(val)}
>
<SelectTrigger
aria-label="File type"
className={cn('w-[5.5rem] sm:w-24', className)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{fileTypeOptions.map(option => (
<SelectItem
key={option.value}
value={option.value}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/* -------------------------------------------------------------------------- */
/* ImageSize subcomponent */
interface ImageSizeProps {
children: React.ReactNode;
width: string;
height: string;
/** Handlers optional. If not provided, default noop. */
onWidthChange?: React.ChangeEventHandler<HTMLInputElement>;
onHeightChange?: React.ChangeEventHandler<HTMLInputElement>;
className?: string;
maxWidth?: string;
maxHeight?: string;
}
function ImageSize({
children,
width,
height,
onWidthChange,
onHeightChange,
className,
maxWidth,
maxHeight,
}: ImageSizeProps) {
return (
<div className={cn('text-foreground space-y-1', className)}>
<label className="block text-base">{children}</label>
{/* Flex container for width/height inputs */}
<div className="flex items-center space-x-4">
{/* Width group */}
<div className="flex items-center space-x-2">
<span className="text-foreground text-base">W</span>
<Input
value={width}
onChange={onWidthChange ?? (() => {})}
placeholder="Width"
className="w-20"
max={maxWidth}
/>
</div>
{/* Height group */}
<div className="text-foreground flex items-center space-x-2 text-base">
<span className="text-foreground text-base">H</span>
<Input
value={height}
onChange={onHeightChange ?? (() => {})}
placeholder="Height"
className="w-20"
max={maxHeight}
/>
</div>
</div>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* SwitchOption subcomponent */
interface SwitchOptionProps {
children: React.ReactNode;
checked?: boolean;
defaultChecked?: boolean;
onCheckedChange?: (checked: boolean) => void;
className?: string;
}
function SwitchOption({
children,
checked,
defaultChecked,
onCheckedChange,
className,
}: SwitchOptionProps) {
return (
<div className={cn('text-foreground flex items-center space-x-2', className)}>
<Switch
checked={checked}
defaultChecked={defaultChecked}
onCheckedChange={val => onCheckedChange?.(val)}
/>
<span className="text-base">{children}</span>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Attach subcomponents onto the main ImageModal function. */
ImageModal.Body = ImageBody;
ImageModal.ImageVisual = ImageVisual;
ImageModal.ImageOptions = ImageOptions;
ImageModal.Filename = Filename;
ImageModal.Filetype = Filetype;
ImageModal.ImageSize = ImageSize;
ImageModal.SwitchOption = SwitchOption;

View File

@ -0,0 +1,120 @@
import * as React from 'react';
import { Label } from '../Label';
import { Input } from '../Input';
import { cn } from '../../lib/utils';
interface UserPreferencesModalProps {
children: React.ReactNode;
className?: string;
}
export function UserPreferencesModal({ children, className }: UserPreferencesModalProps) {
return (
<div className={cn('flex max-h-[80vh] w-full max-w-4xl flex-col overflow-hidden', className)}>
{children}
</div>
);
}
/** Body
* Automatically wraps content in a scrollable area.
*/
interface BodyProps {
children: React.ReactNode;
className?: string;
}
function Body({ children, className }: BodyProps) {
return (
<div className={cn('flex-1 overflow-y-auto', className)}>
<div className={cn('mt-1 mb-4 flex flex-col space-y-4', className)}>{children}</div>
</div>
);
}
/** Subheading
* Section labels
*/
interface SubHeadingProps {
children: React.ReactNode;
className?: string;
}
function SubHeading({ children, className }: SubHeadingProps) {
return <span className={cn('text-muted-foreground text-lg', className)}>{children}</span>;
}
/** Responsive 3-column grid for hotkeys, etc. */
interface HotkeysGridProps {
children: React.ReactNode;
className?: string;
}
function HotkeysGrid({ children, className }: HotkeysGridProps) {
return (
<div className={cn('grid grid-cols-1 gap-3 gap-x-16 md:grid-cols-2 lg:grid-cols-3', className)}>
{children}
</div>
);
}
/** A single hotkey row: label + input */
interface HotkeyProps {
label: string;
placeholder?: string;
className?: string;
value?: string;
onChange?: (value: string) => void;
hotkeys?: {
record: (callback: (sequence: string[]) => void) => void;
pause: () => void;
unpause: () => void;
startRecording: () => void;
};
}
function Hotkey({ label, placeholder, className, value, onChange, hotkeys }: HotkeyProps) {
const [isRecording, setIsRecording] = React.useState(false);
const onInputKeyDown = (event: React.KeyboardEvent) => {
event.preventDefault();
hotkeys?.record((sequence: string[]) => {
const keys = sequence.join('+');
hotkeys?.unpause();
setIsRecording(false);
onChange?.(keys);
});
};
const onFocus = () => {
setIsRecording(true);
hotkeys?.pause();
hotkeys?.startRecording();
};
const onBlur = () => {
setIsRecording(false);
hotkeys?.unpause();
};
return (
<div className={cn('flex items-center justify-between space-x-2', className)}>
<Label className="whitespace-nowrap">{label}</Label>
<Input
className={cn(
'w-16 text-center transition-colors',
isRecording && 'bg-accent text-accent-foreground caret-accent-foreground'
)}
placeholder={isRecording ? 'Press keys...' : placeholder}
value={value}
onKeyDown={onInputKeyDown}
onFocus={onFocus}
onBlur={onBlur}
readOnly={!isRecording}
/>
</div>
);
}
/** Attach subcomponents as static properties for a nicer API */
UserPreferencesModal.Body = Body;
UserPreferencesModal.HotkeysGrid = HotkeysGrid;
UserPreferencesModal.Hotkey = Hotkey;
UserPreferencesModal.SubHeading = SubHeading;

View File

@ -0,0 +1,3 @@
export { UserPreferencesModal } from './UserPreferencesModal';
export { ImageModal } from './ImageModal';
export { AboutModal } from './AboutModal';

View File

@ -54,6 +54,10 @@ import { DisplaySetMessageListTooltip } from './DisplaySetMessageListTooltip';
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from './Tooltip';
import { ToolboxUI, Toolbox } from './OHIFToolbox';
import Numeric from './Numeric';
import { InputDialog, PresetDialog } from './OHIFDialogs';
import { AboutModal, ImageModal, UserPreferencesModal } from './OHIFModals';
import Modal from './Modal/Modal';
import { FooterAction } from './FooterAction';
import {
DropdownMenu,
@ -225,4 +229,11 @@ export {
ToolButtonListDropDown,
ToolButtonListItem,
ToolButtonListDivider,
InputDialog,
PresetDialog,
Modal,
AboutModal,
ImageModal,
UserPreferencesModal,
FooterAction,
};

View File

@ -0,0 +1,89 @@
import React, { useState, createContext, useContext, useCallback, useEffect, useMemo } from 'react';
import ManagedDialog, { ManagedDialogProps } from './ManagedDialog';
interface DialogContextType {
show: (options: ManagedDialogProps) => string;
hide: (id: string) => void;
hideAll: () => void;
isEmpty: () => boolean;
}
interface DialogService {
setServiceImplementation: (implementation: DialogContextType) => void;
getCustomComponent?: () => React.ComponentType<ManagedDialogProps> | null;
}
interface DialogProviderProps {
children: React.ReactNode;
dialog?: React.ComponentType<ManagedDialogProps>;
service?: DialogService | null;
}
const DialogContext = createContext<DialogContextType | null>(null);
export const useDialog = () => {
const context = useContext(DialogContext);
if (!context) {
throw new Error('useDialog must be used within a DialogProvider');
}
return context;
};
const DialogProvider: React.FC<DialogProviderProps> = ({
children,
dialog: DialogComponent = ManagedDialog,
service = null,
}) => {
const [dialogs, setDialogs] = useState<(ManagedDialogProps & { id: string })[]>([]);
const show = useCallback((options: ManagedDialogProps) => {
const id = options.id;
setDialogs(prev => [...prev, { ...options, id }]);
return id;
}, []);
const hide = useCallback((id: string) => {
setDialogs(prev => prev.filter(dialog => dialog.id !== id));
}, []);
const hideAll = useCallback(() => {
setDialogs([]);
}, []);
const isEmpty = useCallback(() => dialogs.length === 0, [dialogs]);
const contextValue = useMemo(
() => ({
show,
hide,
hideAll,
isEmpty,
}),
[show, hide, hideAll, isEmpty]
);
useEffect(() => {
if (service) {
service.setServiceImplementation(contextValue);
}
}, [service, contextValue]);
const CustomDialog = service?.getCustomComponent?.();
const RenderedDialog = CustomDialog || DialogComponent;
return (
<DialogContext.Provider value={contextValue}>
{dialogs.map(dialog => (
<RenderedDialog
key={dialog.id}
onClose={hide}
isOpen={true}
{...dialog}
/>
))}
{children}
</DialogContext.Provider>
);
};
export { DialogProvider };

View File

@ -0,0 +1,76 @@
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../components/Dialog/Dialog';
import { cn } from '../lib/utils';
export interface ManagedDialogProps {
id: string;
isOpen?: boolean;
title?: string;
description?: string;
content: React.ComponentType<{ hide?: () => void }>;
contentProps?: Record<string, unknown>;
isDraggable?: boolean;
shouldCloseOnEsc?: boolean;
shouldCloseOnOverlayClick?: boolean;
defaultPosition?: { x: number; y: number };
onClose?: (id: string) => void;
unstyled?: boolean;
showOverlay?: boolean;
containerClassName?: string;
}
const ManagedDialog: React.FC<ManagedDialogProps> = ({
id,
isOpen,
title,
content: DialogContentComponent,
contentProps,
isDraggable,
shouldCloseOnEsc = false,
shouldCloseOnOverlayClick = false,
showOverlay = true,
defaultPosition,
onClose,
unstyled,
containerClassName,
}) => {
return (
<Dialog
open={isOpen}
modal={false} // keep modal behavior off for independent windows
onOpenChange={open => {
if (!open) {
onClose(id);
}
}}
isDraggable={isDraggable}
shouldCloseOnEsc={shouldCloseOnEsc}
shouldCloseOnOverlayClick={shouldCloseOnOverlayClick}
showOverlay={showOverlay}
>
<DialogContent
className={cn(unstyled ? 'p-0' : '', containerClassName)}
style={{
...(defaultPosition
? {
position: 'fixed',
left: `${defaultPosition.x}px`,
top: `${defaultPosition.y}px`,
transform: 'translate(0, 0)',
margin: 0,
animation: 'none',
}
: {}),
}}
>
{!unstyled && <DialogHeader>{title && <DialogTitle>{title}</DialogTitle>}</DialogHeader>}
<DialogContentComponent
{...contentProps}
hide={() => onClose(id)}
/>
</DialogContent>
</Dialog>
);
};
export default ManagedDialog;

View File

@ -0,0 +1,102 @@
import React, { useState, createContext, useContext, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
interface ModalOptions {
title?: string;
shouldCloseOnEsc?: boolean;
content?: React.ComponentType;
contentProps?: Record<string, unknown>;
containerClassName?: string;
}
interface ModalContextType {
show: (options: Partial<ModalOptions>) => void;
hide: () => void;
containerClassName?: string;
}
interface ModalComponentProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
}
const ModalContext = createContext<ModalContextType | null>(null);
export const useModal = () => {
const ctx = useContext(ModalContext);
if (!ctx) {
throw new Error('useModal must be used within a ModalProvider');
}
return ctx;
};
const DEFAULT_OPTIONS: ModalOptions = {
title: '',
shouldCloseOnEsc: true,
};
interface ModalService {
setServiceImplementation: (implementation: ModalContextType) => void;
getCustomComponent: () => React.ComponentType<ModalComponentProps> | null;
}
interface ModalProviderProps {
children: React.ReactNode;
modal: React.ComponentType<ModalComponentProps>;
service?: ModalService | null;
}
const ModalProvider: React.FC<ModalProviderProps> = ({
children,
modal: ModalComponent,
service = null,
}) => {
const { t } = useTranslation('Modals');
const [options, setOptions] = useState<ModalOptions>(DEFAULT_OPTIONS);
const ModalContent = options.content;
const show = useCallback((props: Partial<ModalOptions>) => {
setOptions(prev => ({ ...prev, ...props }));
}, []);
const hide = useCallback(() => {
setOptions(DEFAULT_OPTIONS);
}, []);
useEffect(() => {
if (service) {
service.setServiceImplementation({ show, hide });
}
}, [hide, service, show]);
const { title } = options;
const CustomModal = service?.getCustomComponent();
const RenderedModal = CustomModal || ModalComponent;
return (
<ModalContext.Provider value={{ show, hide }}>
{ModalContent && (
<RenderedModal
isOpen={true}
onClose={hide}
title={t(title)}
{...options}
>
<ModalContent
{...options.contentProps}
show={show}
hide={hide}
/>
</RenderedModal>
)}
{children}
</ModalContext.Provider>
);
};
export { ModalProvider };
export const ModalConsumer = ModalContext.Consumer;

View File

@ -1,7 +1,13 @@
import NotificationProvider, { useNotification } from './NotificationProvider';
import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './ViewportGridProvider';
import { ToolboxProvider, useToolbox } from './ToolboxContext';
import { ModalProvider, useModal } from './ModalProvider';
import { DialogProvider, useDialog } from './DialogProvider';
import ManagedDialog from './ManagedDialog';
export { useNotification, NotificationProvider };
export { ViewportGridContext, ViewportGridProvider, useViewportGrid };
export { ToolboxProvider, useToolbox };
export { ModalProvider, useModal };
export { DialogProvider, useDialog };
export { ManagedDialog };

View File

@ -100,6 +100,13 @@ import {
ToolButtonListDivider,
Toolbox,
Numeric,
InputDialog,
PresetDialog,
Modal,
AboutModal,
ImageModal,
UserPreferencesModal,
FooterAction,
} from './components';
import { DataRow } from './components/DataRow';
@ -108,6 +115,11 @@ import {
NotificationProvider,
useToolbox,
ToolboxProvider,
useModal,
ModalProvider,
DialogProvider,
useDialog,
ManagedDialog,
} from './contextProviders';
import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './contextProviders';
import * as utils from './utils';
@ -225,4 +237,16 @@ export {
useToolbox,
utils,
Numeric,
AboutModal,
ImageModal,
UserPreferencesModal,
InputDialog,
PresetDialog,
Modal,
useModal,
ModalProvider,
FooterAction,
DialogProvider,
useDialog,
ManagedDialog,
};

View File

@ -37,6 +37,7 @@ class LabellingFlow extends Component<PropType> {
this.state = {
location,
label,
hide: props.hide,
componentClassName: className,
confirmationState: false,
displayComponent: true,
@ -56,33 +57,19 @@ class LabellingFlow extends Component<PropType> {
displayComponent={this.state.displayComponent}
onTransitionExit={this.props.labellingDoneCallback}
>
<>
<div
className={className}
ref={this.mainElement}
>
{this.labellingStateFragment()}
</div>
</>
<div
className={className}
ref={this.mainElement}
>
{this.labellingStateFragment()}
</div>
</LabellingTransition>
);
}
closePopup = () => {
this.setState({
displayComponent: false,
});
setTimeout(() => {
this.setState({
displayComponent: false,
});
}, 2000);
};
selectTreeSelectCalback = (event, itemSelected) => {
const label = itemSelected.value;
this.closePopup();
this.props.hide();
return this.props.labellingDoneCallback(label);
};
@ -92,7 +79,7 @@ class LabellingFlow extends Component<PropType> {
items={this.currentItems}
columns={1}
onSelected={this.selectTreeSelectCalback}
closePopup={this.closePopup}
closePopup={this.props.hide}
selectTreeFirstTitle="Annotation"
exclusive={this.props.exclusive}
label={this.state.label}

View File

@ -22,7 +22,7 @@ const Modal = ({
onClose,
children,
shouldCloseOnOverlayClick = true,
movable = false,
isDraggable = false,
containerDimensions = null,
contentDimensions = null,
}) => {
@ -75,7 +75,7 @@ const Modal = ({
: 'relative max-h-full w-11/12 text-white outline-none lg:w-10/12 xl:w-9/12'
}
overlayClassName={
movable
isDraggable
? 'fixed top-0 left-0 right-0 bottom-0 z-50 flex items-center justify-center py-16 pointer-events-none'
: 'fixed top-0 left-0 right-0 bottom-0 z-50 bg-overlay flex items-center justify-center py-16'
}
@ -85,7 +85,7 @@ const Modal = ({
title={title}
shouldCloseOnOverlayClick={shouldCloseOnOverlayClick}
>
{movable ? (
{isDraggable ? (
<Draggable
handle=".drag-handle"
defaultClassName="bg-primary-dark pointer-events-auto"
@ -107,7 +107,7 @@ Modal.propTypes = {
onClose: PropTypes.func,
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
shouldCloseOnOverlayClick: PropTypes.bool,
movable: PropTypes.bool,
isDraggable: PropTypes.bool,
containerDimensions: PropTypes.string,
contentDimensions: PropTypes.string,
};

View File

@ -168,20 +168,9 @@ export class SelectTree extends Component<PropType> {
headerItem = () => {
const inputLeftPadding = this.props.items.length > 0 ? 'pl-8' : 'pl-4';
const title = this.props.selectTreeFirstTitle;
return (
<div className="flex flex-col justify-between border-b-2 border-solid border-black p-4">
<div className="text-primary-active m-0 mb-5 p-2 leading-tight">
<span className="text-primary-light align-sub text-xl">{title}</span>
<div className="float-right">
<Icons.Close
className="cursor-pointer"
onClick={() => this.props.closePopup()}
fill="#a3a3a3"
/>
</div>
</div>
<div className="flex flex-col justify-between border-b-2 border-solid border-black p-0.5">
{this.props.searchEnabled && (
<div className="flex w-full flex-col">
{this.props.items.length > 0 && (

View File

@ -31,7 +31,7 @@ const ModalProvider = ({ children, modal: Modal, service = null }) => {
closeButton: true,
title: null,
customClassName: '',
movable: false,
isDraggable: false,
containerDimensions: null,
contentDimensions: null,
};
@ -78,7 +78,7 @@ const ModalProvider = ({ children, modal: Modal, service = null }) => {
shouldCloseOnEsc,
closeButton,
shouldCloseOnOverlayClick,
movable,
isDraggable,
containerDimensions,
contentDimensions,
} = options;
@ -96,7 +96,7 @@ const ModalProvider = ({ children, modal: Modal, service = null }) => {
closeButton={closeButton}
onClose={hide}
shouldCloseOnOverlayClick={shouldCloseOnOverlayClick}
movable={movable}
isDraggable={isDraggable}
containerDimensions={containerDimensions}
contentDimensions={contentDimensions}
>