import { useEffect, useRef, useCallback, useState } from "react" import { RenderingEngine, Enums, } from "@cornerstonejs/core" import { init as csRenderInit, utilities as csUtilities } from "@cornerstonejs/core" import { init as csToolsInit, addTool, ToolGroupManager, Enums as csToolsEnums, PanTool, WindowLevelTool, StackScrollTool, ZoomTool, PlanarRotateTool, utilities as csToolsUtilities } from "@cornerstonejs/tools" import { LengthTool, ProbeTool, ArrowAnnotateTool, RectangleROITool, EllipticalROITool, PlanarFreehandROITool, PlanarFreehandContourSegmentationTool, SculptorTool, CrosshairsTool, ReferenceLinesTool, ReferenceCursors } from "@cornerstonejs/tools" import { init as dicomImageLoaderInit } from "@cornerstonejs/dicom-image-loader" import * as cornerstoneTools from '@cornerstonejs/tools'; import { segmentation, Enums as csEnums } from '@cornerstonejs/tools'; import { voi } from "@cornerstonejs/tools/utilities" import { readDicomElementExplicit } from "dicom-parser" import { volumeLoader } from '@cornerstonejs/core'; import { metaData } from '@cornerstonejs/core'; import * as dicomParser from "dicom-parser"; import cornerstoneDICOMImageLoader from "@cornerstonejs/dicom-image-loader" import { NoLabelArrowAnnotateTool } from "./NoLabelArrowAnnotateTool"; import { render } from "@cornerstonejs/tools/tools/displayTools/Labelmap/labelmapDisplay" // Extend the Window interface to include cornerstoneTools declare global { interface Window { cornerstone3dTools: typeof cornerstoneTools; } } function pixelToWorld(imageId: string, point: { x: number, y: number }) { const plane = metaData.get("imagePlaneModule", imageId); if (!plane) return undefined; const { rowCosines, columnCosines, imagePositionPatient, rowPixelSpacing, columnPixelSpacing } = plane; if (!rowCosines || !columnCosines || !imagePositionPatient) return undefined; // Default to 1 if spacing is missing const dx = columnPixelSpacing || 1; const dy = rowPixelSpacing || 1; // Convert cosines to vectors const rc = rowCosines; const cc = columnCosines; const ipp = imagePositionPatient; // World = IPP + x*col*cos*spacing + y*row*cos*spacing return [ ipp[0] + point.x * cc[0] * dx + point.y * rc[0] * dy, ipp[1] + point.x * cc[1] * dx + point.y * rc[1] * dy, ipp[2] + point.x * cc[2] * dx + point.y * rc[2] * dy, ]; } // Add to global types: declare global { interface Window { exportViewerState?: () => string; importViewerState?: (json: string) => void; exportAnnotations?: () => string; importAnnotations?: (json: string) => void; importLegacyAnnotations?: (json: string) => void; importLegacyViewport?: (json: string) => void; } } type StackEntry = { imageIds: string[], studyInstanceUID?: string }; // Add this type for clarity declare global { interface Window { loadDicomStackFromFiles?: (files: FileList | File[]) => void; } } declare global { interface Window { loadDicomStackFromWadouriList?: (wadouriImageIds: string[]) => void; } } window.cornerstone3dTools = cornerstoneTools; type ViewportDiv = HTMLDivElement & { _cornerstoneEnabled?: boolean; _lastViewportType?: any; _resizeObserver?: ResizeObserver; _overlayListener?: EventListener; }; const { MouseBindings, KeyboardBindings } = csToolsEnums; const { IMAGE_RENDERED } = Enums.Events; // Common CT presets (add more as needed) const WINDOW_LEVEL_PRESETS = [ { name: "Soft Tissue", center: 40, width: 400 }, { name: "Lung", center: -600, width: 1500 }, { name: "Bone", center: 300, width: 1500 }, { name: "Brain", center: 40, width: 80 }, { name: "Abdomen", center: 60, width: 400 }, ] // Tool options for dropdowns const TOOL_OPTIONS = [ { label: "Window/Level", value: WindowLevelTool.toolName }, { label: "Pan", value: PanTool.toolName }, { label: "Zoom", value: ZoomTool.toolName }, { label: "Stack Scroll", value: StackScrollTool.toolName }, { label: "Rotate", value: PlanarRotateTool.toolName }, { label: "Length", value: LengthTool.toolName }, { label: "Probe", value: ProbeTool.toolName }, { label: "Arrow (No Label)", value: NoLabelArrowAnnotateTool.toolName }, { label: "Arrow Annotate", value: ArrowAnnotateTool.toolName }, { label: "Rectangle ROI", value: RectangleROITool.toolName }, { label: "Ellipse ROI", value: EllipticalROITool.toolName }, { label: "Freehand ROI", value: PlanarFreehandROITool.toolName }, { label: "Freehand Contour Segmentation", value: PlanarFreehandContourSegmentationTool.toolName }, { label: "Sculptor", value: SculptorTool.toolName }, { label: "Reference Cursors", value: ReferenceCursors.toolName }, ] // Mouse button labels const MOUSE_BUTTONS = [ { label: "Left", value: "Primary" }, { label: "Middle", value: "Auxiliary" }, { label: "Right", value: "Secondary" }, ] const ALL_MOUSE_BINDINGS = [ { label: "Left", value: "Primary", mask: 1 }, { label: "Middle", value: "Auxiliary", mask: 4 }, { label: "Right", value: "Secondary", mask: 2 }, { label: "Left + Right", value: "Primary_and_Secondary", mask: 3 }, { label: "Left + Middle", value: "Primary_and_Auxiliary", mask: 5 }, { label: "Right + Middle", value: "Secondary_and_Auxiliary", mask: 6 }, { label: "Left + Right + Middle", value: "Primary_and_Secondary_and_Auxiliary", mask: 7 }, { label: "Fourth Button", value: "Fourth_Button", mask: 8 }, { label: "Fifth Button", value: "Fifth_Button", mask: 16 }, { label: "Mouse Wheel", value: "Wheel", mask: 524288 }, { label: "Mouse Wheel + Left", value: "Wheel_Primary", mask: 524289 }, ]; type AppProps = { container_id?: string; imageStacks: () => Promise; autoCacheStack?: boolean; // <-- new prop // ...other props if needed }; // App definintion function App({ container_id, imageStacks, autoCacheStack, ...props }: AppProps) { const elementRef = useRef(null) const running = useRef(false) // State to control preset menu open/close const [presetsOpen, setPresetsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [fullscreenHoverIdx, setFullscreenHoverIdx] = useState(null); const [openDropdownIdx, setOpenDropdownIdx] = useState(null); const MAX_VIEWPORTS = 9; const renderingEngineId = "renderingEngine_" + container_id; const MAIN_TOOL_GROUP_ID = "TOOL_GROUP_" + container_id; const renderingEngineRef = useRef(null); const restoringStateRef = useRef(false); const [availableStacks, setAvailableStacks] = useState([]); const [activeViewport, setActiveViewport] = useState(0); const [viewportImageIds, setViewportImageIds] = useState( () => Array(MAX_VIEWPORTS).fill([]) ); const [selectedStackIdx, setSelectedStackIdx] = useState( () => Array(MAX_VIEWPORTS).fill(0) ); // Load available stacks on mount useEffect(() => { imageStacks().then(async stacks => { // Try to extract StudyInstanceUID for each stack const stackEntries: StackEntry[] = await Promise.all( stacks.map(async imageIds => { let studyInstanceUID: string | undefined = undefined; try { // Try to fetch the first image as a blob and parse it const firstId = imageIds[0]; if (firstId && firstId.startsWith("wadouri:")) { const url = firstId.slice(8); const response = await fetch(url); const arrayBuffer = await response.arrayBuffer(); const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); studyInstanceUID = dataSet.string('x0020000d'); } } catch (e) { // Ignore errors, fallback to undefined } return { imageIds, studyInstanceUID }; }) ); setAvailableStacks(stackEntries); //setAvailableStacks(stacks.map(imageIds => ({ imageIds, studyInstanceUID: undefined }))); await setLoadedImageIdsAndCacheMeta(stacks[0]); setViewportImageIds(Array(MAX_VIEWPORTS).fill(stacks[0] || [])); }); }, []); const handleLoadStack = async (viewportIdx: number, stackIdx: number) => { const stackObj = availableStacks[stackIdx]; if (!stackObj) return; const stack = stackObj.imageIds; // Always revert to stack mode when loading a new stack setViewportModes(prev => { const next = [...prev]; next[viewportIdx] = 'stack'; return next; }); setViewportImageIds(prev => { const next = [...prev]; next[viewportIdx] = stack; return next; }); setSelectedStackIdx(prev => { const next = [...prev]; next[viewportIdx] = stackIdx; return next; }); // Cache imageIds and load metadata for this stack await setLoadedImageIdsAndCacheMeta(stack); }; const [crossHairsEnabled, setCrossHairsEnabled] = useState(false); const [referenceLinesEnabled, setReferenceLinesEnabled] = useState(true); const [showAdvancedMouseBindings, setShowAdvancedMouseBindings] = useState(false); const [showAdvancedCtrlMouseBindings, setShowAdvancedCtrlMouseBindings] = useState(false); const sortViewportImageIdsBySliceLocation = () => { console.log("Sorting viewport imageIds by slice location"); setViewportImageIds(prev => { const next = prev.map((imageIds, idx) => { if (!imageIds || imageIds.length === 0) return imageIds; const withSortKey = imageIds.map(id => { const plane = metaData.get('imagePlaneModule', id); let sortKey = null; if (plane) { if (plane.sliceLocation !== undefined) { sortKey = Number(plane.sliceLocation); } else if ( plane.imagePositionPatient && Array.isArray(plane.imagePositionPatient) && plane.imagePositionPatient.length === 3 ) { sortKey = Number(plane.imagePositionPatient[2]); } } return { imageId: id, sortKey }; }); const sorted = [...withSortKey].sort((a, b) => { if (a.sortKey !== null && b.sortKey !== null) { return a.sortKey - b.sortKey; } return a.imageId.localeCompare(b.imageId); }); const sortedIds = sorted.map(obj => obj.imageId); if (JSON.stringify(sortedIds) === JSON.stringify(imageIds)) { return imageIds; } return sortedIds; }); return next; }); }; // Wrap setLoadedImageIds to also cache DICOM metadata const setLoadedImageIdsAndCacheMeta = async (imageIds: string[]) => { console.log("Setting loaded imageIds:", imageIds); console.log("autoCacheStack:", autoCacheStack); const loadPromises: Promise[] = []; if (autoCacheStack) { for (const imageId of imageIds) { if (imageId.startsWith("wadouri:")) { console.log("Caching metadata for imageId:", imageId); try { const url = imageId.slice(8); if (cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager) { // @ts-ignore const result = cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager.load(url); if (result && typeof result.then === "function") { loadPromises.push(result); } } else { console.warn("MetaDataManager not found. Unable to cache metadata."); } } catch (e) { console.warn(`Failed to cache metadata for ${imageId}:`, e); } } } // Wait for all metadata loads to finish await Promise.all(loadPromises); // Trigger sort if enabled if (orderBySliceLocation) { console.log("Sorting viewport imageIds by slice location"); sortViewportImageIdsBySliceLocation(); } } }; const [viewportGrid, setViewportGrid] = useState({ rows: 1, cols: 1 }) const [viewportMenuOpen, setViewportMenuOpen] = useState(false) // Store refs for each viewport const viewportRefs = useRef>([]); // Store the properties and scroll position of existing viewports before grid change const prevViewportProps = useRef>({}); const prevScrollIndices = useRef>({}); const [orderBySliceLocation, setOrderBySliceLocation] = useState(true); const [altEnablesReferenceCursors, setAltEnablesReferenceCursors] = useState(true); // Helper to generate grid options const gridOptions = [] for (let rows = 1; rows <= 3; rows++) { for (let cols = 1; cols <= 3; cols++) { gridOptions.push({ rows, cols }) } } // State for overlay info per viewport const [imageInfos, setImageInfos] = useState>(() => Array(viewportGrid.rows * viewportGrid.cols).fill({ index: 0, total: 0, windowCenter: "", windowWidth: "", }) ); const [viewportModes, setViewportModes] = useState>( () => Array(MAX_VIEWPORTS).fill('stack') ); // State for preset menu open/close per viewport const [presetsOpenArr, setPresetsOpenArr] = useState( () => Array(viewportGrid.rows * viewportGrid.cols).fill(false) ); // State for selected tool per mouse button const [mouseToolBindings, setMouseToolBindings] = useState({ Primary: StackScrollTool.toolName, Auxiliary: PanTool.toolName, Secondary: WindowLevelTool.toolName, Primary_and_Secondary: ZoomTool.toolName, // 1 (left) + 2 (right) = 3 Fourth_Button: PlanarRotateTool.toolName, // 16 Wheel: StackScrollTool.toolName, // 524288 }) const [ctrlMouseToolBindings, setCtrlMouseToolBindings] = useState({ Primary: LengthTool.toolName, Auxiliary: ProbeTool.toolName, Secondary: ArrowAnnotateTool.toolName, }) // Track if Ctrl is pressed const [ctrlPressed, setCtrlPressed] = useState(false) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.ctrlKey) setCtrlPressed(true) } const handleKeyUp = (e: KeyboardEvent) => { if (!e.ctrlKey) setCtrlPressed(false) } window.addEventListener("keydown", handleKeyDown) window.addEventListener("keyup", handleKeyUp) return () => { window.removeEventListener("keydown", handleKeyDown) window.removeEventListener("keyup", handleKeyUp) } }, []) const [metaModalOpen, setMetaModalOpen] = useState(false); const [metaModalContent, setMetaModalContent] = useState(null); const handleSelectAnnotation = (viewportIdx: number, uid: string | null) => { // Use the official selection API for Cornerstone3D Tools if (cornerstoneTools?.annotation?.selection?.setAnnotationSelected) { cornerstoneTools.annotation.selection.setAnnotationSelected(uid); } // Optionally, update your own UI state setSelectedAnnUIDs(prev => { const next = [...prev]; next[viewportIdx] = uid; return next; }); }; const handleDeleteAnnotation = (viewportIdx: number, uid: string, currentAnnotations: any[]) => { const allAnnotations = cornerstoneTools.annotation.state.getAllAnnotations() || []; allAnnotations.forEach(ann => { if (ann.annotationUID === uid) ann.isSelected = false; }); cornerstoneTools.annotation.state.removeAnnotation(uid); renderingEngineRef.current.render(); //} // Auto-select another annotation if available const remaining = currentAnnotations.filter(ann => ann.annotationUID !== uid); setSelectedAnnUIDs(prev => { const next = [...prev]; const newSelected = remaining.length > 0 ? remaining[0].annotationUID : null; next[viewportIdx] = newSelected; allAnnotations.forEach(ann => { ann.isSelected = ann.annotationUID === newSelected; }); cornerstoneTools.annotation.selection.setAnnotationSelected(newSelected); renderingEngineRef.current.render(); return next; }); }; // Add this function to gather and format metadata: const handleShowMetadata = () => { // Find the first visible viewport with images const firstIdx = viewportImageIds.findIndex(ids => ids && ids.length > 0); if (firstIdx === -1) { setMetaModalContent("No images loaded."); setMetaModalOpen(true); return; } const imageId = viewportImageIds[firstIdx][0]; // Gather all available metadata modules for this imageId const modules = [ "imagePlaneModule", "generalSeriesModule", "generalStudyModule", "patientModule", "imagePixelModule", "voiLutModule", "modalityLutModule", "sopCommonModule", "petIsotopeModule", "multiframeModule", "cineModule", "overlayPlaneModule", "generalImageModule", "stackModule", "voiLutModule", "modalityLutModule", "petSeriesModule", "petImageModule", "petIsotopeModule", "petSeriesModule", "petImageModule", "petIsotopeModule", "petSeriesModule", "petImageModule", "petIsotopeModule", "petSeriesModule", "petImageModule", "petIsotopeModule", // Add more if your app or cornerstone supports them ]; const allMeta: Record = {}; modules.forEach((mod) => { const val = metaData.get(mod, imageId); if (val !== undefined) allMeta[mod] = val; }); setMetaModalContent(
        {JSON.stringify(allMeta, null, 2)}
      
); setMetaModalOpen(true); }; // Inside your App component: useEffect(() => { window.loadDicomStackFromFiles = (files: FileList | File[]) => { // Convert FileList to array if needed const fileArray = Array.from(files); // Your logic to handle the files and load them as a stack // For example, call your existing handler: handleFilesSelected({ target: { files: fileArray } } as any); }; // Cleanup on unmount return () => { window.loadDicomStackFromFiles = undefined; }; }, []); const [altPressed, setAltPressed] = useState(false); useEffect(() => { const mainToolGroup = ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID); if (!mainToolGroup) return; // List all annotation tools you use [ 'ArrowAnnotate', 'NoLabelArrowAnnotate', 'Length', 'RectangleROI', 'EllipticalROI', 'PlanarFreehandROI', 'PlanarFreehandContourSegmentation', // Add more if needed ].forEach(toolName => { mainToolGroup.setToolConfiguration(toolName, { color: '#00e676', // normal color selectedColor: '#ffeb3b', // yellow for selected }); }); }, [MAIN_TOOL_GROUP_ID]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.altKey) setAltPressed(true); }; const handleKeyUp = (e: KeyboardEvent) => { if (!e.altKey) setAltPressed(false); }; window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); }; }, []); useEffect(() => { if (!altEnablesReferenceCursors) return; const mainToolGroup = ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID); if (!mainToolGroup) return; if (altPressed) { if (!mainToolGroup.hasTool(ReferenceCursors.toolName)) { mainToolGroup.addTool(ReferenceCursors.toolName); } mainToolGroup.setToolEnabled(ReferenceCursors.toolName); mainToolGroup.setToolActive(ReferenceCursors.toolName); } else { if (mainToolGroup.hasTool(ReferenceCursors.toolName)) { mainToolGroup.setToolDisabled(ReferenceCursors.toolName); } } }, [altPressed, altEnablesReferenceCursors]); // Helper to apply all mouse tool bindings (normal or ctrl) const applyMouseToolBindings = useCallback(() => { // Apply to both stack and volume tool groups const mainToolGroup = cornerstoneTools.ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID); [mainToolGroup].forEach(toolGroup => { if (toolGroup) { // Set all tools to passive first, removing all bindings TOOL_OPTIONS.forEach(opt => { toolGroup.setToolPassive(opt.value, { removeAllBindings: true }); }); toolGroup.setToolDisabled(ReferenceCursors.toolName); // Collect all bindings for each tool const toolBindings: Record> = {}; // Normal bindings Object.entries(mouseToolBindings).forEach(([btn, tool]) => { if (!toolBindings[tool]) toolBindings[tool] = []; // Find the mask for this binding const binding = ALL_MOUSE_BINDINGS.find(b => b.value === btn); if (binding) { toolBindings[tool].push({ mouseButton: binding.mask }); } else if (MouseBindings[btn]) { // Fallback for legacy bindings toolBindings[tool].push({ mouseButton: MouseBindings[btn] }); } }); // Ctrl bindings (now supports advanced mouse bindings) Object.entries(ctrlMouseToolBindings).forEach(([btn, tool]) => { if (!tool) return; if (!toolBindings[tool]) toolBindings[tool] = []; const binding = ALL_MOUSE_BINDINGS.find(b => b.value === btn); if (binding) { toolBindings[tool].push({ mouseButton: binding.mask, modifierKey: KeyboardBindings.Ctrl, }); } else if (MouseBindings[btn]) { toolBindings[tool].push({ mouseButton: MouseBindings[btn], modifierKey: KeyboardBindings.Ctrl, }); } }); // Apply all bindings for each tool in a single call Object.entries(toolBindings).forEach(([tool, bindings]) => { toolGroup.setToolActive(tool, { bindings }); }); //// Always re-apply the mouse wheel binding for StackScrollTool //toolGroup.setToolActive(StackScrollTool.toolName, { // bindings: [ // { // mouseButton: MouseBindings.Wheel, // Wheel Mouse // }, // ], //}); } }); }, [mouseToolBindings, ctrlMouseToolBindings]); const [sideMenuOpen, setSideMenuOpen] = useState(false); // Add a ref to store selected files const fileInputRef = useRef(null); // Handler for file input change const handleFilesSelected = async (event: React.ChangeEvent | { target: { files: File[] | FileList } }) => { const files = event.target.files; if (!files || files.length === 0) return; setOrderBySliceLocation(false); const fileArray = Array.from(files).sort((a, b) => a.name.localeCompare(b.name)); let studyInstanceUID: string | undefined = undefined; const arrayBuffer = await fileArray[0].arrayBuffer(); const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); studyInstanceUID = dataSet.string('x0020000d'); console.log("StudyInstanceUID:", studyInstanceUID); const imageIds = fileArray.map( file => `wadouri:${URL.createObjectURL(file)}` ); setAvailableStacks(prev => { const next = [...prev, { imageIds, studyInstanceUID }]; setViewportImageIds(vpPrev => { const vpNext = [...vpPrev]; vpNext[0] = imageIds; return vpNext; }); setSelectedStackIdx(selPrev => { const selNext = [...selPrev]; selNext[0] = next.length - 1; return selNext; }); setLoadedImageIdsAndCacheMeta(imageIds); return next; }); }; const handleToolChange = (mouseButton: string, toolName: string) => { setMouseToolBindings(prev => ({ ...prev, [mouseButton]: toolName })) } // Update overlay info for a specific viewport const updateOverlay = useCallback((viewportIdx: number, viewport: any) => { if (!viewport) return; const currentIndex = typeof viewport.getCurrentImageIdIndex === "function" ? viewport.getCurrentImageIdIndex() : 0; const imageIds = typeof viewport.getImageIds === "function" ? viewport.getImageIds() : []; const total = imageIds.length; let windowCenter = ""; let windowWidth = ""; if (typeof viewport.getProperties === "function") { const props = viewport.getProperties(); if (props.voiRange) { const { lower, upper } = props.voiRange; windowCenter = ((upper + lower) / 2).toFixed(0); windowWidth = (upper - lower).toFixed(0); } } setImageInfos(prev => { const next = [...prev]; next[viewportIdx] = { index: currentIndex + 1, total, windowCenter, windowWidth, }; return next; }); }, []); const [gridBtnActive, setGridBtnActive] = useState(false); function saveVisibleViewportState() { for (let i = 0; i < viewportGrid.rows * viewportGrid.cols; i++) { const viewportId = `CT_${i}`; const renderingEngine = renderingEngineRef.current; if (renderingEngine) { const viewport = renderingEngine.getViewport(viewportId); if (viewport && typeof viewport.getProperties === "function") { prevViewportProps.current[i] = viewport.getProperties(); } if (viewport && typeof viewport.getCurrentImageIdIndex === "function") { prevScrollIndices.current[i] = viewport.getCurrentImageIdIndex(); } } } } useEffect(() => { const setup = async () => { console.log("setup") // 1. Initialize Cornerstone3D and tools ONCE if (!running.current) { running.current = true; await csRenderInit(); await csToolsInit( { showSVGCursors: true, } ); dicomImageLoaderInit({ maxWebWorkers: 1 }); } // 3. Get or create the rendering engine let renderingEngine = renderingEngineRef.current; if (!renderingEngine) { renderingEngine = new RenderingEngine(renderingEngineId); renderingEngineRef.current = renderingEngine; } // --- In your setup function, use the correct tool group per viewport --- let mainToolGroup = ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID); if (!mainToolGroup) { mainToolGroup = setupTools(MAIN_TOOL_GROUP_ID) } const visibleVolumeViewports = viewportModes .slice(0, viewportGrid.rows * viewportGrid.cols) .filter(mode => mode === 'volume').length; // --- In your setup function, after tool group creation, enable/disable ReferenceLinesTool for each tool group --- updateActiveReferenceLineViewport(mainToolGroup) // 6. Enable and configure each viewport for (let i = 0; i < MAX_VIEWPORTS; i++) { let imageIds = viewportImageIds[i]; if (!imageIds || imageIds.length === 0) continue; const viewportId = `CT_${i}`; const element = viewportRefs.current[i]; if (!element) continue; const viewportType = viewportModes[i] === 'volume' ? Enums.ViewportType.ORTHOGRAPHIC // or Enums.ViewportType.VOLUME if supported : Enums.ViewportType.STACK; // Choose tool group based on viewport type //const toolGroup = viewportModes[i] === 'volume' ? volumeToolGroup : stackToolGroup; if (!element._cornerstoneEnabled || element._lastViewportType !== viewportType) { if (renderingEngine.getViewport(viewportId)) { renderingEngine.disableElement(viewportId); } renderingEngine.enableElement({ viewportId, type: viewportType, element, }); element._cornerstoneEnabled = true; element._lastViewportType = viewportType; //mainToolGroup.addViewport(viewportId, renderingEngineId); //toolGroup.addViewport(viewportId, renderingEngineId); } const viewport = renderingEngine.getViewport(viewportId); if (!viewport) continue; if (!element._resizeObserver) { const resizeObserver = new window.ResizeObserver(() => { if (element.offsetWidth > 0 && element.offsetHeight > 0) { renderingEngine.resize(); // Always get the latest viewport instance const vp = renderingEngine.getViewport(viewportId); if (vp && typeof vp.resize === "function") { vp.resize(); } if (vp && typeof vp.render === "function") { vp.render(); } } }); resizeObserver.observe(element); element._resizeObserver = resizeObserver; } if (viewportModes[i] === 'stack') { if (imageIds && !restoringStateRef.current) { viewport.setStack(imageIds); } // CrosshairsTool is not applicable for stack viewports if (!crossHairsEnabled) { mainToolGroup.addViewport(viewportId, renderingEngineId); } else { mainToolGroup.removeViewports(renderingEngineId, viewportId); } // Restore scroll, properties, etc. } else if (viewportModes[i] === 'volume') { mainToolGroup.addViewport(viewportId, renderingEngineId); //let sortedImageIds = imageIds; //if (imageIds && imageIds.length > 1) { // sortedImageIds = [...imageIds].sort((a, b) => { // const metaA = metaData.get('imagePlaneModule', a); // const metaB = metaData.get('imagePlaneModule', b); // const sliceA = metaA && metaA.sliceLocation !== undefined ? Number(metaA.sliceLocation) : 0; // const sliceB = metaB && metaB.sliceLocation !== undefined ? Number(metaB.sliceLocation) : 0; // return sliceA - sliceB; // }); //} // Use a unique volumeId per viewport const volumeId = `myVolumeId_${i}`; const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: imageIds }); volume.load(); viewport.setVolumes([{ volumeId }]); viewport.render(); continue } viewport.render(); if (!restoringStateRef.current) { // Restore previous scroll position for existing viewports only if ( prevScrollIndices.current[i] !== undefined && typeof viewport.setImageIdIndex === "function" ) { //viewport.setImageIdIndex(prevScrollIndices.current[i]); csUtilities.jumpToSlice(viewport.element, { imageIndex: prevScrollIndices.current[i] }) } // Restore previous state for existing viewports only const prevProps = prevViewportProps.current[i]; if (prevProps && typeof viewport.setProperties === "function") { // Only restore safe properties const { voiRange, invert, interpolationType, VOILUTFunction, colormap } = prevProps; const propsToRestore: any = {}; if (voiRange && voiRange.lower !== undefined && voiRange.upper !== undefined) propsToRestore.voiRange = voiRange; if (invert !== undefined) propsToRestore.invert = invert; if (interpolationType !== undefined) propsToRestore.interpolationType = interpolationType; if (VOILUTFunction) propsToRestore.VOILUTFunction = VOILUTFunction; if ( colormap !== undefined && colormap !== null && (typeof colormap === "string" || (typeof colormap === "object" && !("actor" in colormap))) ) { propsToRestore.colormap = colormap; } setTimeout(() => { console.log("Restoring properties (timeout) for viewport", i, propsToRestore); viewport.setProperties(propsToRestore); viewport.render() }, 100); } } // Always (re-)attach overlay update for each viewport if ((element)._overlayListener) { // Remove previous listener if present viewport.element.removeEventListener(IMAGE_RENDERED, (element)._overlayListener); } const overlayListener = () => updateOverlay(i, viewport); viewport.element.addEventListener(IMAGE_RENDERED, overlayListener); (element)._overlayListener = overlayListener; updateOverlay(i, viewport); } const activeVolumeViewports = []; for (let i = 0; i < viewportGrid.rows * viewportGrid.cols; i++) { const viewportId = `CT_${i}`; const viewport = renderingEngineRef.current?.getViewport(viewportId); if (viewport && viewportModes[i] === 'volume') { activeVolumeViewports.push(viewport); } } // Find all active volume viewports const activeVolumeViewportIds = []; for (let i = 0; i < viewportGrid.rows * viewportGrid.cols; i++) { if (viewportModes[i] === 'volume') { activeVolumeViewportIds.push(`CT_${i}`); } } if (mainToolGroup && crossHairsEnabled) { // Only enable CrosshairsTool if at least two volume viewports are active if (activeVolumeViewportIds.length >= 2) { // Make sure CrosshairsTool is added to the tool group if (!mainToolGroup.hasTool(CrosshairsTool.toolName)) { mainToolGroup.addTool(CrosshairsTool.toolName); } mainToolGroup.setToolEnabled(CrosshairsTool.toolName); mainToolGroup.setToolConfiguration(CrosshairsTool.toolName, { // Example: color: '#ffeb3b', lineWidth: 2 }); mainToolGroup.setToolActive(CrosshairsTool.toolName, { bindings: [{ mouseButton: MouseBindings.Primary }], }); } else { // Disable CrosshairsTool if not enough volume viewports if (mainToolGroup.hasTool(CrosshairsTool.toolName)) { mainToolGroup.setToolDisabled(CrosshairsTool.toolName); } } } // 7. Apply mouse tool bindings applyMouseToolBindings(); }; // Use requestAnimationFrame to ensure DOM is painted before setup const raf = requestAnimationFrame(() => { setup(); }); // Cleanup: disconnect ResizeObservers return () => { viewportRefs.current.forEach(el => { if (el && (el)._resizeObserver) { (el)._resizeObserver.disconnect(); delete (el)._resizeObserver; } }); cancelAnimationFrame(raf); }; }, [elementRef, updateOverlay, viewportGrid, viewportModes, viewportImageIds]); // Handler to reset the view (camera and window/level) const handleDoubleClick = useCallback((viewportIdx: number) => { const viewportId = `CT_${viewportIdx}`; const renderingEngine = renderingEngineRef.current; const viewport = renderingEngine.getViewport(viewportId); if (viewport && typeof viewport.resetCamera === "function") { viewport.resetCamera(); console.log("Restoring viewport properties for viewport", viewportIdx); viewport.resetProperties(); // Resets window/level and other properties viewport.render(); } }, []) // Handler to apply a preset for a specific viewport const handleApplyPreset = useCallback((viewportIdx: number, center: number, width: number) => { const viewportId = `CT_${viewportIdx}`; try { const renderingEngine = renderingEngineRef.current; if (renderingEngine) { const viewport = renderingEngine.getViewport(viewportId); if (viewport && typeof viewport.setProperties === "function") { console.log(`Applying preset to viewport ${viewportIdx}: center=${center}, width=${width}`); viewport.setProperties({ voiRange: { lower: center - width / 2, upper: center + width / 2, }, }); viewport.render(); updateOverlay(viewportIdx, viewport); } } } catch (e) { // Ignore if not found } }, [updateOverlay]); // Update overlay and preset state arrays when grid changes useEffect(() => { setImageInfos(Array(viewportGrid.rows * viewportGrid.cols).fill({ index: 0, total: 0, windowCenter: "", windowWidth: "", })); setPresetsOpenArr(Array(viewportGrid.rows * viewportGrid.cols).fill(false)); }, [viewportGrid]); // Re-apply bindings when tool sets or ctrl changes useEffect(() => { applyMouseToolBindings() }, [mouseToolBindings, ctrlMouseToolBindings, ctrlPressed, applyMouseToolBindings]) // Close the menu when clicking outside useEffect(() => { if (!presetsOpen) return; const handleClick = (e: MouseEvent) => { const menu = document.querySelector(".preset-menu"); if (menu && !menu.contains(e.target as Node)) { setPresetsOpen(false); } }; document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, [presetsOpen]); useEffect(() => { const handleContextMenu = (e: MouseEvent) => { e.preventDefault(); }; document.addEventListener("contextmenu", handleContextMenu); return () => { document.removeEventListener("contextmenu", handleContextMenu); }; }, []); useEffect(() => { if (!referenceLinesEnabled) return; const mainToolGroup = ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID); updateActiveReferenceLineViewport(mainToolGroup) }, [activeViewport, referenceLinesEnabled]); useEffect(() => { if (fileInputRef.current) { fileInputRef.current.setAttribute("webkitdirectory", "true"); fileInputRef.current.setAttribute("directory", "true"); } }, []); useEffect(() => { // API: Load a stack from a list of wadouri imageIds (strings) window.loadDicomStackFromWadouriList = async (wadouriImageIds: string[]) => { if (!wadouriImageIds || wadouriImageIds.length === 0) return; // Try to extract StudyInstanceUID from the first image if possible let studyInstanceUID: string | undefined = undefined; try { const firstId = wadouriImageIds[0]; if (firstId && firstId.startsWith("wadouri:")) { const url = firstId.slice(8); const response = await fetch(url); const arrayBuffer = await response.arrayBuffer(); const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); studyInstanceUID = dataSet.string('x0020000d'); } } catch (e) { // Ignore errors, fallback to undefined } setAvailableStacks(prev => { const next = [...prev, { imageIds: wadouriImageIds, studyInstanceUID }]; setViewportImageIds(vpPrev => { const vpNext = [...vpPrev]; vpNext[0] = wadouriImageIds; return vpNext; }); setSelectedStackIdx(selPrev => { const selNext = [...selPrev]; selNext[0] = next.length - 1; return selNext; }); setLoadedImageIdsAndCacheMeta(wadouriImageIds); return next; }); }; // Cleanup return () => { window.loadDicomStackFromWadouriList = undefined; }; }, [setAvailableStacks, setViewportImageIds, setSelectedStackIdx, setLoadedImageIdsAndCacheMeta]); useEffect(() => { if (!orderBySliceLocation) return; // Only sort if all metadata is loaded for all viewports const allLoaded = viewportImageIds.every(imageIds => !imageIds || imageIds.length === 0 || imageIds.every(id => !!metaData.get("imagePlaneModule", id)) ); if (!allLoaded) return; setViewportImageIds(prev => { const next = prev.map(imageIds => { if (!imageIds || imageIds.length === 0) return imageIds; const withSliceLoc = imageIds.map(id => ({ imageId: id, sliceLoc: (() => { const meta = metaData.get('imagePlaneModule', id); return meta && meta.sliceLocation !== undefined ? Number(meta.sliceLocation) : null; })(), })); const sorted = [...withSliceLoc].sort((a, b) => { if (a.sliceLoc !== null && b.sliceLoc !== null) { return a.sliceLoc - b.sliceLoc; } return a.imageId.localeCompare(b.imageId); }); const sortedIds = sorted.map(obj => obj.imageId); if (JSON.stringify(sortedIds) === JSON.stringify(imageIds)) { return imageIds; } return sortedIds; }); return next; }); }, [orderBySliceLocation, availableStacks]); function updateActiveReferenceLineViewport(mainToolGroup: cornerstoneTools.Types.IToolGroup) { // Ensure the main tool group is defined (it won't be during setup) if (!mainToolGroup) return; const sourceViewportId = `CT_${activeViewport}` if (referenceLinesEnabled) { mainToolGroup.setToolEnabled(ReferenceLinesTool.toolName) mainToolGroup.setToolConfiguration(ReferenceLinesTool.toolName, { sourceViewportId, }) } else { mainToolGroup.setToolDisabled(ReferenceLinesTool.toolName) } } useEffect(() => { const apiKey = container_id || "default"; function jumpToSliceByImageId(viewportIdx: number, imageId: string) { const renderingEngine = renderingEngineRef.current; if (!renderingEngine) return; const viewport = renderingEngine.getViewport(`CT_${viewportIdx}`); if (!viewport || typeof viewport.getImageIds !== "function") return; const imageIds = viewport.getImageIds(); // Some stacks may have imageIds with or without "wadouri:" prefix, so normalize for comparison const normImageIds = imageIds.map(id => id.replace(/^wadouri:/, "")); const normTarget = imageId.replace(/^wadouri:/, ""); const idx = normImageIds.indexOf(normTarget); if (idx !== -1 && typeof viewport.setImageIdIndex === "function") { csUtilities.jumpToSlice(viewport.element, { imageIndex: idx }); updateOverlay(viewportIdx, viewport); } } // Attach to window with a unique name window[`jumpToSliceByImageId_${apiKey}`] = jumpToSliceByImageId; // Export the current viewer state as JSON window[`exportViewerState_${apiKey}`] = () => { const numActive = viewportGrid.rows * viewportGrid.cols; const modes = viewportModes.slice(0, numActive); const stackIdx = selectedStackIdx.slice(0, numActive); const stacks = availableStacks.map(s => ({ i: s.imageIds, // imageIds u: s.studyInstanceUID // studyInstanceUID })); // Only keep minimal properties const renderingEngine = renderingEngineRef.current; const props: any[] = []; const stackPos: number[] = []; const cam: any[] = []; for (let i = 0; i < numActive; i++) { let p = null, s = null, c = null; if (renderingEngine) { const viewportId = `CT_${i}`; const viewport = renderingEngine.getViewport(viewportId); if (viewport) { if (typeof viewport.getProperties === "function") { const { voiRange, invert, interpolationType, VOILUTFunction, colormap } = viewport.getProperties(); p = { voiRange, invert, interpolationType, VOILUTFunction, colormap }; } if (typeof viewport.getCurrentImageIdIndex === "function") { s = viewport.getCurrentImageIdIndex(); } if (typeof viewport.getCamera === "function") { c = viewport.getCamera(); } } } props.push(p); stackPos.push(s); cam.push(c); } // Use short keys for compactness const state = { grid: viewportGrid, modes, stacks, stackIdx, stackPos, props, cam, }; return JSON.stringify(state); }; // Import and restore a viewer state from JSON (using stack indices) window[`importViewerState_${apiKey}`] = (json: string) => { try { restoringStateRef.current = true; const state = typeof json === "string" ? JSON.parse(json) : json; if (state.grid) setViewportGrid(state.grid); if (state.modes) setViewportModes(state.modes); if (state.stacks) setAvailableStacks(state.stacks.map((s: any) => ({ imageIds: s.i, studyInstanceUID: s.u, }))); if (state.stackIdx && state.stacks) { const reconstructed = state.stackIdx.map( (idx: number) => state.stacks[idx]?.i || [] ); setViewportImageIds(reconstructed); setSelectedStackIdx(state.stackIdx); } setTimeout(() => { const renderingEngine = renderingEngineRef.current; if (!renderingEngine) { restoringStateRef.current = false; return; } const numActive = state.grid?.rows * state.grid?.cols || 1; for (let i = 0; i < numActive; i++) { const viewportId = `CT_${i}`; const viewport = renderingEngine.getViewport(viewportId); if (!viewport) continue; // Restore stack position if ( state.stackPos && typeof state.stackPos[i] === "number" && typeof viewport.setImageIdIndex === "function" ) { if (csUtilities && csUtilities.jumpToSlice) { csUtilities.jumpToSlice(viewport.element, { imageIndex: state.stackPos[i] }); } else { viewport.setImageIdIndex(state.stackPos[i]); } } // Restore properties if ( state.props && state.props[i] && typeof viewport.setProperties === "function" ) { viewport.setProperties(state.props[i]); viewport.render(); } // Restore camera if ( state.cam && state.cam[i] && typeof viewport.setCamera === "function" ) { viewport.setCamera(state.cam[i]); viewport.render(); } } restoringStateRef.current = false; }, 300); } catch (e) { restoringStateRef.current = false; alert("Failed to import viewer state: " + e); } }; // Export all annotations using cornerstoneTools.annotation.state.getAllAnnotations() window[`exportAnnotations_${apiKey}`] = () => { try { let allAnnotations = cornerstoneTools.annotation.state.getAllAnnotations(); console.log("Exporting annotations:", allAnnotations); // Remove ReferenceLines annotations (toolName may be 'ReferenceLines') if (Array.isArray(allAnnotations)) { // Only keep annotations with toolName in the allowed list const allowedTools = [ LengthTool.toolName, NoLabelArrowAnnotateTool.toolName, ArrowAnnotateTool.toolName, RectangleROITool.toolName, EllipticalROITool.toolName, PlanarFreehandROITool.toolName, PlanarFreehandContourSegmentationTool.toolName, ProbeTool.toolName, SculptorTool.toolName, ]; allAnnotations = allAnnotations.filter( ann => allowedTools.includes(ann.metadata.toolName) ); } else { return "{}"; // If no annotations found, return empty object } return JSON.stringify(allAnnotations, null, 2); } catch (e) { alert("Failed to export annotations: " + e); return "{}"; } }; // Import and restore all annotations using cornerstoneTools.annotation.state.restoreAnnotations() window[`importAnnotations_${apiKey}`] = (json: string) => { try { const annotationData = typeof json === "string" ? JSON.parse(json) : json; // Remove all existing annotations if needed if (cornerstoneTools.annotation.state.removeAllAnnotations) { cornerstoneTools.annotation.state.removeAllAnnotations(); } // Add each annotation from the imported data if (Array.isArray(annotationData)) { // For now just add them to a new group //const group = new cornerstoneTools.annotation.AnnotationGroup(); annotationData.forEach(ann => { cornerstoneTools.annotation.state.addAnnotation(ann, ann.annotationUID); }); } if (renderingEngineRef.current) { renderingEngineRef.current.render(); } } catch (e) { alert("Failed to import annotations: " + e); } }; window[`importLegacyAnnotations_${apiKey}`] = (json: string) => { // Remove all existing annotations if (cornerstoneTools.annotation.state.removeAllAnnotations) { cornerstoneTools.annotation.state.removeAllAnnotations(); } let legacyData: any; try { legacyData = typeof json === "string" ? JSON.parse(json) : json; } catch (e) { alert("Invalid legacy annotation JSON"); return; } const toolNameMap: Record = { ArrowAnnotate: "ArrowAnnotate", // Add more mappings if needed }; Object.entries(legacyData).forEach(([imageId, tools]) => { console.log("Importing legacy annotations for imageId:", imageId); Object.entries(tools as any).forEach(([legacyTool, toolData]: [string, any]) => { const cs3Tool = toolNameMap[legacyTool] || legacyTool; if (toolData.data && Array.isArray(toolData.data)) { toolData.data.forEach((ann: any) => { // Convert pixel handles to world coordinates const startWorld = pixelToWorld(imageId, { x: ann.handles.start.y, y: ann.handles.start.x }); const endWorld = pixelToWorld(imageId, { x: ann.handles.end.y, y: ann.handles.end.x }); console.log("Importing legacy annotation:") console.log(" Image ID:", imageId); console.log(" Start World:", startWorld); console.log(" End World:", endWorld); // Get spatial metadata const plane = metaData.get("imagePlaneModule", imageId); console.log(metaData); let FrameOfReferenceUID = metaData.get("frameOfReferenceUID", imageId); if (!FrameOfReferenceUID && plane && plane.frameOfReferenceUID) { FrameOfReferenceUID = plane.frameOfReferenceUID; } const viewPlaneNormal = plane?.rowCosines && plane?.columnCosines ? [ plane.rowCosines[1] * plane.columnCosines[2] - plane.rowCosines[2] * plane.columnCosines[1], plane.rowCosines[2] * plane.columnCosines[0] - plane.rowCosines[0] * plane.columnCosines[2], plane.rowCosines[0] * plane.columnCosines[1] - plane.rowCosines[1] * plane.columnCosines[0], ] : undefined; // Only include viewPlaneNormal if it's a valid 3-element array const metadata: any = { toolName: cs3Tool, referencedImageId: imageId, FrameOfReferenceUID, }; if (Array.isArray(viewPlaneNormal) && viewPlaneNormal.length === 3) { metadata.viewPlaneNormal = viewPlaneNormal as [number, number, number]; } // --- Add rotation if present in legacy annotation --- // Does this actually exist? if (typeof ann.rotation === "number") { metadata.rotation = ann.rotation; } const annotation = { annotationUID: ann.uuid, // || cornerstoneTools.utilities.uuidv4(), metadata: { toolName: cs3Tool, referencedImageId: imageId, FrameOfReferenceUID, viewPlaneNormal, // Optionally add viewUp, cameraFocalPoint, etc. }, data: { handles: { points: [startWorld, endWorld], textBox: ann.handles.textBox, }, text: ann.text || "", // Add more fields as needed }, visibility: ann.visible !== false, active: ann.active || false, }; // @ts-expect-error legacy cornerstoneTools.annotation.state.addAnnotation(annotation, annotation.annotationUID); }); } }); }); if (renderingEngineRef.current) { renderingEngineRef.current.render(); } }; // Import and apply a legacy viewport state to the active viewport window[`importLegacyViewport_${apiKey}`] = (json: string) => { let legacy: any; try { legacy = typeof json === "string" ? JSON.parse(json) : json; } catch (e) { alert("Invalid legacy viewport JSON"); return; } const renderingEngine = renderingEngineRef.current; if (!renderingEngine) return; const viewportId = `CT_${activeViewport ?? 0}`; const viewport = renderingEngine.getViewport(viewportId); if (!viewport) return; // Map legacy fields to Cornerstone3D properties const props: any = {}; if (legacy.voi) { props.voiRange = { lower: legacy.voi.windowCenter - legacy.voi.windowWidth / 2, upper: legacy.voi.windowCenter + legacy.voi.windowWidth / 2, }; } if (typeof legacy.invert === "boolean") props.invert = legacy.invert; if (typeof legacy.pixelReplication === "boolean") props.interpolationType = legacy.pixelReplication ? 0 : 1; if (typeof legacy.hflip === "boolean") props.hflip = legacy.hflip; if (typeof legacy.vflip === "boolean") props.vflip = legacy.vflip; if (typeof viewport.setProperties === "function") { viewport.setProperties(props); } // Apply translation and scale (pan/zoom) if (legacy.translation || legacy.scale) { const camera = viewport.getCamera ? viewport.getCamera() : {}; if (legacy.translation) { camera.translation = { ...camera.translation, ...legacy.translation }; } if (legacy.scale) { camera.parallelScale = 200 / legacy.scale; } console.log("Applying legacy camera properties:", camera); if (typeof viewport.setCamera === "function") { viewport.setCamera(camera); } } // --- Fix: Import rotation from legacy viewport --- if (typeof legacy.rotation === "number") { viewport.setRotation(legacy.rotation); } viewport.render(); updateOverlay(activeViewport ?? 0, viewport); }; // Cleanup return () => { window[`exportViewerState_${apiKey}`] = undefined; window[`importViewerState_${apiKey}`] = undefined; window[`exportAnnotations_${apiKey}`] = undefined; window[`importAnnotations_${apiKey}`] = undefined; window[`importLegacyAnnotations_${apiKey}`] = undefined; window[`importLegacyViewport_${apiKey}`] = undefined; window[`jumpToSliceByImageId_${apiKey}`] = undefined; }; }, [ viewportGrid, viewportModes, selectedStackIdx, availableStacks, viewportImageIds, setViewportGrid, setViewportModes, setSelectedStackIdx, setAvailableStacks, setViewportImageIds, ]); // Reference stack and UID for study comparison (use active viewport) const referenceStackIdx = activeViewport !== null ? selectedStackIdx[activeViewport] : 0; const referenceStackObj = availableStacks[referenceStackIdx]; const referenceUID = referenceStackObj?.studyInstanceUID; const [selectedAnnUIDs, setSelectedAnnUIDs] = useState<(string | null)[]>(() => Array(MAX_VIEWPORTS).fill(null) ); const numVisible = viewportGrid.rows * viewportGrid.cols; const viewports = []; //for (let i = 0; i < MAX_VIEWPORTS; i++) { // Viewports rendering loop for (let i = 0; i < numVisible; i++) { // Determine if this viewport should be visible in the current grid const isVisible = i < viewportGrid.rows * viewportGrid.cols; viewports.push(
(viewportRefs.current[i] = el)} style={{ flex: 1, border: isVisible ? "1px solid #222" : "none", // Only show border if visible background: "#000", minWidth: 0, minHeight: 0, position: "relative", overflow: "hidden", flexDirection: "column", gridRow: Math.floor(i / viewportGrid.cols) + 1, gridColumn: (i % viewportGrid.cols) + 1, height: "100%", //visibility: isVisible ? "visible" : "hidden", //pointerEvents: isVisible ? "auto" : "none", // Remove outline, use only boxShadow for indicator boxShadow: activeViewport === i ? "0 0 0 4px #1976d2, 0 0 16px 4px #1976d2cc" : undefined, // cursor: "pointer", zIndex: activeViewport === i ? 10 : 1, }} onClick={() => setActiveViewport(i)} onDoubleClick={() => handleDoubleClick(i)} > {viewportModes[i] === 'volume' && (
)} {/* Fullscreen button below the Volume/Stack toggle */} // Annotations buttons {viewportModes[i] === "stack" && (() => { // Get all annotations for this stack const imageIds = viewportImageIds[i]; const allAnnotations = cornerstoneTools.annotation.state.getAllAnnotations() || []; const stackAnnotations = allAnnotations.filter( ann => ann.metadata && ann.metadata.referencedImageId && imageIds.includes(ann.metadata.referencedImageId) && ann.metadata.toolName !== ReferenceCursors.toolName // Exclude ReferenceCursors ); if (!stackAnnotations.length) return null; // Find the annotation(s) on the current image const renderingEngine = renderingEngineRef.current; const viewport = renderingEngine?.getViewport(`CT_${i}`); const currentIdx = viewport?.getCurrentImageIdIndex?.() ?? 0; const currentImageId = imageIds[currentIdx]; const currentAnnotations = stackAnnotations.filter( ann => ann.metadata.referencedImageId === currentImageId ); // Use per-viewport selection state const selectedAnnUID = selectedAnnUIDs[i]; return (
{/* Prev/Next buttons ... */} {/* List and select/delete individual annotations */} {currentAnnotations.length > 0 && (
{currentAnnotations.map(ann => (
handleSelectAnnotation(i, ann.annotationUID)} > {selectedAnnUID === ann.annotationUID ? "★ " : ""} {ann.metadata.toolName || "Annotation"}
))}
)}
); })()} // ...existing code... {/* Overlay info (bottom left of viewport) */}
{viewportModes[i] === "stack" ? ( <>
Image: {imageInfos[i]?.index} / {imageInfos[i]?.total}
WC: {imageInfos[i]?.windowCenter}   WW: {imageInfos[i]?.windowWidth}
) : ( // Volume mode: do not show image position
WC: {imageInfos[i]?.windowCenter}   WW: {imageInfos[i]?.windowWidth}
)}
{/* Preset menu (bottom right of viewport) */}
setPresetsOpenArr(prev => { const next = [...prev]; next[i] = !next[i]; return next; }) } > Presets {presetsOpenArr[i] ? "▲" : "▼"}
{presetsOpenArr[i] && (
{WINDOW_LEVEL_PRESETS.map((preset) => ( ))}
)}
{/* Position bar inside each viewport */} {viewportModes[i] === "stack" && viewportImageIds[i] && (
0 ? "flex" : "none", alignItems: "flex-start", justifyContent: "center", pointerEvents: "none", flexDirection: "column", padding: 0, }} >
{viewportImageIds[i].map((id, idx) => { const loaded = !!metaData.get("imagePlaneModule", id); const renderingEngine = renderingEngineRef.current; let currentIdx = -1; if ( renderingEngine && typeof renderingEngine.getViewport === "function" ) { const viewport = renderingEngine.getViewport(`CT_${i}`); if (viewport && typeof viewport.getCurrentImageIdIndex === "function") { currentIdx = viewport.getCurrentImageIdIndex(); } } const isCurrent = idx === currentIdx; return (
{ const renderingEngine = renderingEngineRef.current; const viewport = renderingEngine?.getViewport(`CT_${i}`); if ( viewport && typeof viewport.setImageIdIndex === "function" && viewportModes[i] === "stack" ) { //viewport.setImageIdIndex(idx); csUtilities.jumpToSlice(viewport.element, { imageIndex: idx }) //viewport.render(); updateOverlay(i, viewport); } }} /> ); })}
)} {isVisible && availableStacks.length > 1 && (
{/* Custom Stack Dropdown */}
{openDropdownIdx === i && (
{ if (el) el.focus(); }} tabIndex={0} style={{ position: "absolute", left: 0, bottom: "110%", background: "#222", border: "1px solid #444", borderRadius: "4px", boxShadow: "0 2px 8px rgba(0,0,0,0.3)", zIndex: 100, minWidth: 180, maxHeight: 220, overflowY: "auto", overscrollBehavior: "contain", scrollbarWidth: "thin", }} role="listbox" onMouseDown={e => e.stopPropagation()} onWheel={e => { // Trap scroll inside the dropdown const target = e.currentTarget as HTMLDivElement; const { scrollTop, scrollHeight, clientHeight } = target; const atTop = scrollTop === 0; const atBottom = scrollTop + clientHeight >= scrollHeight - 1; if ( (e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom) ) { // Let the event bubble to parent (viewport) return; } // Otherwise, prevent scrolling the viewport e.stopPropagation(); e.preventDefault(); // Manually scroll the menu target.scrollTop += e.deltaY; }} onBlur={e => { const related = e.relatedTarget as Node; const parent = e.currentTarget.parentElement; if (!parent?.contains(related)) { setTimeout(() => setOpenDropdownIdx(null), 100); } }} > {availableStacks.map((stackObj, idx) => { const thisUID = stackObj.studyInstanceUID; const sameStudy = referenceUID && thisUID && referenceUID === thisUID; const dotColor = sameStudy ? "#4caf50" : "#888"; const isEmpty = stackObj.imageIds.length === 0; return (
{ e.preventDefault(); if (!isEmpty) { handleLoadStack(i, idx); setOpenDropdownIdx(null); } }} > Stack {idx + 1} ({stackObj.imageIds.length} images {isEmpty && (empty)})
); })}
)}
)}
); } return ( // App root
{/* Side menu toggle button */} {/* Side menu drawer */} {sideMenuOpen && (
Menu
setOrderBySliceLocation(e.target.checked)} style={{ accentColor: "#666" }} />
{/* Add more menu items here if needed */}
)}
{/* Viewport grid menu at top center */}
{/* Transparent hit area to catch hover/focus */}
setGridBtnActive(true)} onFocus={() => setGridBtnActive(true)} tabIndex={-1} /> {viewportMenuOpen && (
{gridOptions.map(opt => ( ))}
)}
{/* Viewport grid (inside main div) */}
{viewports}
{/* Settings button at top right */} {/* Settings Modal (viewport-local, not fixed to page) */} { settingsOpen && (
setSettingsOpen(false)} >
e.stopPropagation()} >
Settings
{/* Settings content: Tool dropdowns */}
Mouse Button Tool Bindings
{MOUSE_BUTTONS.map((btn) => (
))} {/* Expandable advanced mouse bindings */}
{showAdvancedMouseBindings && (
{ALL_MOUSE_BINDINGS.slice(3).map((btn) => (
))}
)}
{/* Ctrl + Mouse Button Tool Bindings */}
Ctrl + Mouse Button Tool Bindings
{MOUSE_BUTTONS.map((btn) => (
))} {/* Separate button for advanced Ctrl+Mouse bindings */}
{showAdvancedCtrlMouseBindings && (
{ALL_MOUSE_BINDINGS.slice(3).map((btn) => (
))}
)}
) } { metaModalOpen && (
setMetaModalOpen(false)} >
e.stopPropagation()} >
DICOM Metadata
{metaModalContent}
) } {/* ...rest of your UI... */}
) } export default App function setupTools(MAIN_TOOL_GROUP_ID) { console.log('Setting up tools...', MAIN_TOOL_GROUP_ID); cornerstoneTools.addTool(PanTool) cornerstoneTools.addTool(WindowLevelTool) cornerstoneTools.addTool(StackScrollTool) cornerstoneTools.addTool(ZoomTool) cornerstoneTools.addTool(PlanarRotateTool) cornerstoneTools.addTool(LengthTool) cornerstoneTools.addTool(ProbeTool) cornerstoneTools.addTool(ArrowAnnotateTool) cornerstoneTools.addTool(RectangleROITool) cornerstoneTools.addTool(EllipticalROITool) cornerstoneTools.addTool(PlanarFreehandROITool) cornerstoneTools.addTool(PlanarFreehandContourSegmentationTool) cornerstoneTools.addTool(SculptorTool) cornerstoneTools.addTool(ReferenceCursors) cornerstoneTools.addTool(CrosshairsTool) cornerstoneTools.addTool(ReferenceLinesTool); cornerstoneTools.addTool(NoLabelArrowAnnotateTool); let mainToolGroup = ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID); if (!mainToolGroup) { mainToolGroup = ToolGroupManager.createToolGroup(MAIN_TOOL_GROUP_ID); // Add all tools you need for both stack and volume mainToolGroup.addTool(WindowLevelTool.toolName); mainToolGroup.addTool(PanTool.toolName); mainToolGroup.addTool(ZoomTool.toolName); mainToolGroup.addTool(StackScrollTool.toolName, { loop: false }); mainToolGroup.addTool(PlanarRotateTool.toolName); mainToolGroup.addTool(LengthTool.toolName); mainToolGroup.addTool(NoLabelArrowAnnotateTool.toolName, { getTextCallback: () => '' }); mainToolGroup.addTool(ArrowAnnotateTool.toolName); mainToolGroup.addTool(ProbeTool.toolName, { statsCalculator: () => null }); mainToolGroup.addTool(RectangleROITool.toolName, { calculateStats: false, getTextCallback: () => '' }); mainToolGroup.addTool(EllipticalROITool.toolName, { getTextCallback: () => '' }); mainToolGroup.addTool(PlanarFreehandROITool.toolName, { calculateStats: false }); mainToolGroup.addTool(PlanarFreehandContourSegmentationTool.toolName, {}); mainToolGroup.addTool(SculptorTool.toolName, {}); //mainToolGroup.addTool(CrosshairsTool.toolName); mainToolGroup.addTool(ReferenceLinesTool.toolName); mainToolGroup.addTool(ReferenceCursors.toolName); } return mainToolGroup; } // Add this helper to find the next/previous annotation index for the current viewport function findNextAnnotationIdx(currentIdx: number, imageIds: string[], annotations: any[], direction: 1 | -1) { if (!annotations.length || !imageIds.length) return null; // Find all annotation indices in this stack const annIndices = annotations .map(ann => imageIds.findIndex(id => id === ann.metadata.referencedImageId)) .filter(idx => idx !== -1) .sort((a, b) => a - b); if (!annIndices.length) return null; // Find the next/prev annotation index if (direction === 1) { // Next for (let idx of annIndices) { if (idx > currentIdx) return idx; } return annIndices[0]; // wrap around } else { // Previous for (let i = annIndices.length - 1; i >= 0; i--) { if (annIndices[i] < currentIdx) return annIndices[i]; } return annIndices[annIndices.length - 1]; // wrap around } }