Files
dv3d/dicom-viewer/src/App.tsx
T

3807 lines
140 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 * as cornerstone3d from '@cornerstonejs/core';
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;
cornerstone3d: typeof cornerstone3d;
cornerstoneDICOMImageLoader: typeof cornerstoneDICOMImageLoader;
}
}
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;
name?: string;
caseId?: string;
studyId?: 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;
window.cornerstone3d = cornerstone3d
window.cornerstoneDICOMImageLoader = cornerstoneDICOMImageLoader;
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<Array<string[] | { imageIds: string[], name?: string, caseId?: string }>>;
autoCacheStack?: boolean; // <-- new prop
annotationJson?: string;
viewerState?: string;
// ...other props if needed
};
// App definintion
function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewerState, ...props }: AppProps) {
const appRootRef = useRef<HTMLDivElement>(null);
const elementRef = useRef<HTMLDivElement>(null)
const running = useRef(false)
const MOUSE_SETTINGS_KEY = "dv3d_mouseToolBindings";
const CTRL_MOUSE_SETTINGS_KEY = "dv3d_ctrlMouseToolBindings";
// State to control preset menu open/close
const [presetsOpen, setPresetsOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [fullscreenHoverIdx, setFullscreenHoverIdx] = useState<number | null>(null);
const [openDropdownIdx, setOpenDropdownIdx] = useState<number | null>(null);
const MAX_VIEWPORTS = 9;
const renderingEngineId = "renderingEngine_" + container_id;
const MAIN_TOOL_GROUP_ID = "TOOL_GROUP_" + container_id;
const renderingEngineRef = useRef<any>(null);
const restoringStateRef = useRef(false);
const [annotationNavEnabled, setAnnotationNavEnabled] = useState(true);
const [availableStacks, setAvailableStacks] = useState<StackEntry[]>([]);
const [activeViewport, setActiveViewport] = useState<number | null>(0);
// After your useState for viewportImageIds:
const [viewportImageIds, _setViewportImageIds] = useState<string[][]>(
() => Array(MAX_VIEWPORTS).fill([])
);
// Replace all setViewportImageIds calls with this function:
const setViewportImageIds = (value: React.SetStateAction<string[][]>) => {
// Log every call
if (typeof value === "function") {
// If called with a function, log the result
_setViewportImageIds(prev => {
const next = (value as (prev: string[][]) => string[][])(prev);
console.log("setViewportImageIds (fn):", next);
return next;
});
} else {
console.log("setViewportImageIds:", value);
_setViewportImageIds(value);
}
};
const [selectedStackIdx, setSelectedStackIdx] = useState<number[]>(
() => Array(MAX_VIEWPORTS).fill(0)
);
const [stackOrderModified, setStackOrderModified] = useState(false);
const [loadingState, setLoadingState] = useState<null | "annotations" | "viewerState">(null);
// Load annotations and viewer state from props if provided
useEffect(() => {
let didCancel = false;
// Wait until availableStacks and viewportImageIds are loaded (i.e., app is ready)
if (annotationJson || viewerState) {
setLoadingState(annotationJson ? "annotations" : "viewerState");
// Use a short delay to ensure Cornerstone is initialized and viewports are ready
const timeout = setTimeout(() => {
if (didCancel) return;
const apiKey = container_id || "default";
if (annotationJson) {
try {
const fn = window[`importAnnotations_${apiKey}`] || window.importAnnotations;
if (typeof fn === "function") {
fn(annotationJson);
}
} catch (e) {
console.error("Failed to import annotations from prop:", e);
}
}
if (viewerState) {
try {
const fn = window[`importViewerState_${apiKey}`] || window.importViewerState;
if (typeof fn === "function") {
fn(viewerState);
}
} catch (e) {
console.error("Failed to import viewer state from prop:", e);
}
}
setLoadingState(null);
}, 2000); // 500ms delay, adjust as needed
return () => {
didCancel = true;
clearTimeout(timeout);
setLoadingState(null);
};
}
}, [annotationJson, viewerState, container_id]);
// Load available stacks on mount
useEffect(() => {
let mounted = true;
imageStacks()
.then(async (descriptors) => {
if (!mounted) return;
const stacksRaw = Array.isArray(descriptors) ? descriptors : [];
const normalized: Array<StackEntry> = await Promise.all(
stacksRaw.map(async (d) => {
if (Array.isArray(d)) {
return { imageIds: d as string[] };
}
const imageIds = Array.isArray((d as any).imageIds) ? (d as any).imageIds : (Array.isArray((d as any).i) ? (d as any).i : []);
// try to preserve old studyInstanceUID extraction if needed (best-effort)
let studyInstanceUID: string | undefined = (d as any).studyInstanceUID;
// optional: attempt to fetch first file and extract StudyInstanceUID if dicomParser available
if (!studyInstanceUID && imageIds.length > 0) {
try {
const first = imageIds[0];
if (typeof fetch === 'function' && typeof (window as any).dicomParser !== 'undefined' && first.startsWith('wadouri:')) {
const url = first.slice(7);
const resp = await fetch(url);
const buffer = await resp.arrayBuffer();
const ds = (window as any).dicomParser.parseDicom(new Uint8Array(buffer));
studyInstanceUID = ds.string('x0020000d');
}
} catch (e) {
// ignore UID extraction failures
}
}
return {
imageIds,
studyInstanceUID,
studyId: (d as any).studyId,
name: (d as any).name,
caseId: (d as any).caseId,
};
})
);
setAvailableStacks(normalized);
// Seed viewports with first stack if present (preserve old behavior)
if (normalized[0]) {
await setLoadedImageIdsAndCacheMeta(normalized[0].imageIds);
setViewportImageIds(Array(MAX_VIEWPORTS).fill(normalized[0].imageIds || []));
} else {
setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
}
})
.catch(err => {
console.error("Failed to load imageStacks:", err);
setAvailableStacks([]);
setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
});
return () => { mounted = false; };
}, []);
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;
});
setStackOrderModified(true);
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<any>[] = [];
if (autoCacheStack) {
for (const imageId of imageIds) {
if (imageId.startsWith("wadouri:")) {
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<Array<ViewportDiv | null>>([]);
// Store the properties and scroll position of existing viewports before grid change
const prevViewportProps = useRef<Record<number, any>>({});
const prevScrollIndices = useRef<Record<number, number>>({});
const [orderBySliceLocation, setOrderBySliceLocation] = useState(false);
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<{
index: number,
total: number,
windowCenter: string,
windowWidth: string,
slicePosition: string,
}>>(() =>
Array(viewportGrid.rows * viewportGrid.cols).fill({
index: 0,
total: 0,
windowCenter: "",
windowWidth: "",
slicePosition: 0,
})
);
const [viewportModes, setViewportModes] = useState<Array<'stack' | 'volume'>>(
() => Array(MAX_VIEWPORTS).fill('stack')
);
// State for preset menu open/close per viewport
const [presetsOpenArr, setPresetsOpenArr] = useState<boolean[]>(
() => 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: NoLabelArrowAnnotateTool.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<any>(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<string, any> = {};
modules.forEach((mod) => {
const val = metaData.get(mod, imageId);
if (val !== undefined) allMeta[mod] = val;
});
setMetaModalContent(
<pre style={{ maxHeight: 600, overflow: "auto", fontSize: 13, color: "#fff" }}>
{JSON.stringify(allMeta, null, 2)}
</pre>
);
setMetaModalOpen(true);
};
// --- Load from localStorage on mount ---
useEffect(() => {
try {
const mouse = localStorage.getItem(MOUSE_SETTINGS_KEY);
const ctrl = localStorage.getItem(CTRL_MOUSE_SETTINGS_KEY);
if (mouse) setMouseToolBindings(JSON.parse(mouse));
if (ctrl) setCtrlMouseToolBindings(JSON.parse(ctrl));
} catch (e) {
// Ignore parse errors
}
}, []);
// --- Save to localStorage when changed ---
useEffect(() => {
try {
localStorage.setItem(MOUSE_SETTINGS_KEY, JSON.stringify(mouseToolBindings));
} catch (e) { }
}, [mouseToolBindings]);
useEffect(() => {
try {
localStorage.setItem(CTRL_MOUSE_SETTINGS_KEY, JSON.stringify(ctrlMouseToolBindings));
} catch (e) { }
}, [ctrlMouseToolBindings]);
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<string, Array<any>> = {};
// 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<HTMLInputElement | null>(null);
// Handler for file input change
const handleFilesSelected = async (event: React.ChangeEvent<HTMLInputElement> | { 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;
let currentIndex = 0;
let total = 0;
let windowCenter = "";
let windowWidth = "";
let slicePosition = "";
if (viewportModes[viewportIdx] === "stack") {
currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
? viewport.getCurrentImageIdIndex()
: 0;
const imageIds = typeof viewport.getImageIds === "function"
? viewport.getImageIds()
: [];
total = imageIds.length;
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);
}
}
} else if (viewportModes[viewportIdx] === "volume") {
// For volume, get slice index and number of slices if available
if (typeof viewport.getSliceIndex === "function") {
currentIndex = viewport.getSliceIndex();
}
if (typeof viewport.getNumberOfSlices === "function") {
total = viewport.getNumberOfSlices();
}
// Get VOI from getProperties (may need to pass volumeId if multiple volumes)
if (typeof viewport.getProperties === "function") {
let props;
if (viewport.volumeIds && viewport.volumeIds.size > 0) {
// If multiple volumes, get the first one
const volumeId = Array.from(viewport.volumeIds)[0];
props = viewport.getProperties(volumeId);
} else {
props = viewport.getProperties();
}
if (props && props.voiRange) {
const { lower, upper } = props.voiRange;
windowCenter = ((upper + lower) / 2).toFixed(0);
windowWidth = (upper - lower).toFixed(0);
}
}
// Try to get slice position in mm (distance along viewPlaneNormal from origin)
if (typeof viewport.getSlicePlaneCoordinates === "function") {
const coords = viewport.getSlicePlaneCoordinates();
if (coords && typeof coords.position === "number") {
slicePosition = coords.position.toFixed(2) + " mm";
}
}
}
setImageInfos(prev => {
const next = [...prev];
next[viewportIdx] = {
index: currentIndex + 1,
total,
windowCenter,
windowWidth,
slicePosition, // add this field
};
return next;
});
}, [viewportModes]);
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(
);
dicomImageLoaderInit();
}
// 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 && imageIds.length > 0) {
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();
// 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);
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 preventExtraButtonDefault = (e: MouseEvent) => {
// 4th button: button === 3, 5th button: button === 4
if (e.button === 3 || e.button === 4) {
e.preventDefault();
//e.stopPropagation();
return false;
}
};
window.addEventListener("mousedown", preventExtraButtonDefault, true);
window.addEventListener("mouseup", preventExtraButtonDefault, true);
window.addEventListener("auxclick", preventExtraButtonDefault, true);
return () => {
window.removeEventListener("mousedown", preventExtraButtonDefault, true);
window.removeEventListener("mouseup", preventExtraButtonDefault, true);
window.removeEventListener("auxclick", preventExtraButtonDefault, true);
};
}, []);
useEffect(() => {
const el = appRootRef.current;
if (!el) return;
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
};
el.addEventListener("contextmenu", handleContextMenu);
return () => {
el.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 => {
let modified = false;
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)) {
modified = true;
return sortedIds;
}
return imageIds;
});
if (modified) setStackOrderModified(true);
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)
}
}
// Inside App component
function resetViewer() {
const renderingEngine = renderingEngineRef.current;
if (renderingEngine) {
for (let i = 0; i < MAX_VIEWPORTS; i++) {
const viewport = renderingEngine.getViewport(`CT_${i}`);
if (viewport) {
viewport.resetCamera?.();
viewport.resetProperties?.();
viewport.render?.();
}
}
}
if (cornerstoneTools?.annotation?.state?.removeAllAnnotations) {
cornerstoneTools.annotation.state.removeAllAnnotations();
}
}
function truncateStack(lower: number, upper: number) {
// Only allow when 1 viewport and 1 stack is loaded
console.log("Truncating stack from", lower, "to", upper);
console.log(stackOrderModified)
if (
viewportGrid.rows !== 1 ||
viewportGrid.cols !== 1 ||
availableStacks.length !== 1 ||
!viewportImageIds[0] ||
viewportImageIds[0].length === 0 ||
stackOrderModified
) {
alert("Truncation is only allowed when a single stack is loaded in a single viewport (and the order has not been modified in the viewer).");
return false;
}
const stack = availableStacks[0];
const imageIds = stack.imageIds;
// Validate bounds
if (
lower < 0 ||
upper >= imageIds.length ||
lower >= upper
) {
alert("Invalid truncation bounds.");
return false;
}
// Truncate the stack and update state
const truncatedImageIds = imageIds.slice(lower, upper + 1);
setAvailableStacks([{ ...stack, imageIds: truncatedImageIds }]);
setViewportImageIds([truncatedImageIds]);
setSelectedStackIdx([0]);
// Optionally reset scroll position, overlays, etc.
// Optionally, force a re-render or reset the viewport
const renderingEngine = renderingEngineRef.current;
if (renderingEngine) {
const viewport = renderingEngine.getViewport("CT_0");
if (viewport && typeof viewport.setStack === "function") {
viewport.setStack(truncatedImageIds);
viewport.setImageIdIndex?.(0);
viewport.render?.();
}
}
return true;
}
function getCurrentStackPosition(viewportIdx: number = 0): number | null {
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) return null;
const viewport = renderingEngine.getViewport(`CT_${viewportIdx}`);
if (!viewport || typeof viewport.getCurrentImageIdIndex !== "function") return null;
return viewport.getCurrentImageIdIndex();
}
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[`getViewport_${apiKey}`] = (idx: number) => {
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) return undefined;
return renderingEngine.getViewport(`CT_${idx}`);
};
window[`getAllViewports_${apiKey}`] = () => {
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) return [];
// Return all currently active viewports as an array
return Array.from({ length: MAX_VIEWPORTS }, (_, i) => renderingEngine.getViewport(`CT_${i}`)).filter(Boolean);
};
window[`jumpToSliceByImageId_${apiKey}`] = jumpToSliceByImageId;
window[`resetViewer_${apiKey}`] = resetViewer;
window[`truncateStack_${apiKey}`] = truncateStack;
window[`getCurrentStackPosition_${apiKey}`] = getCurrentStackPosition;
window[`loadAdditionalStack_${apiKey}`] = async (imageIds: string[], studyInstanceUID?: string) => {
if (!Array.isArray(imageIds) || imageIds.length === 0) return;
// Optionally extract StudyInstanceUID if not provided
let uid = studyInstanceUID;
if (!uid && imageIds[0]?.startsWith("wadouri:")) {
try {
const url = imageIds[0].slice(8);
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
uid = dataSet.string('x0020000d');
} catch (e) {
// Ignore errors, fallback to undefined
}
}
setAvailableStacks(prev => [...prev, { imageIds, studyInstanceUID: uid }]);
// Optionally, auto-populate a viewport with the new stack if there is an empty viewport
setViewportImageIds(prev => {
const next = [...prev];
const emptyIdx = next.findIndex(ids => !ids || ids.length === 0);
if (emptyIdx !== -1) {
next[emptyIdx] = imageIds;
}
return next;
});
setLoadedImageIdsAndCacheMeta(imageIds);
};
// 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
}));
console.log("Exporting viewer state with", numActive, "active viewports");
console.log("modes", modes);
// Only keep minimal properties
const renderingEngine = renderingEngineRef.current;
const props: any[] = [];
const stackPos: number[] = [];
const cam: any[] = [];
const orientations: (string | null)[] = [];
const volumeSliceIndices: (number | null)[] = [];
for (let i = 0; i < numActive; i++) {
let p = null, s = null, c = null, o = null, vIdx = null;
console.log("Exporting state for viewport", i);
if (renderingEngine) {
console.log("Rendering engine found, exporting state for viewport", i);
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();
}
console.log("viewport", viewportId, "properties", p);
// --- Volume orientation and slice index ---
if (modes[i] === "volume") {
console.log("Getting orientation for viewport", i);
console.log("viewport.viewportProperties", viewport.viewportProperties);
o = viewport.viewportProperties?.orientation || null;
if (typeof viewport.getSliceIndex === "function") {
vIdx = viewport.getSliceIndex();
}
}
}
}
props.push(p);
stackPos.push(s);
cam.push(c);
orientations.push(o);
volumeSliceIndices.push(vIdx);
}
// Use short keys for compactness
const state = {
grid: viewportGrid,
modes,
stacks,
stackIdx,
stackPos,
props,
cam,
orientations, // <-- add this
volumeSliceIndices, // <-- add this
};
return JSON.stringify(state);
};
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);
}
// --- Delay the start of tryRestore until after the next paint ---
const maxAttempts = 30;
let attempts = 0;
// eslint-disable-next-line no-inner-declarations
async function tryRestore() {
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) {
restoringStateRef.current = false;
return;
}
const numActive = state.grid?.rows * state.grid?.cols || 1;
let allReady = true;
for (let i = 0; i < numActive; i++) {
console.log("Checking viewport", i);
const viewport = renderingEngine.getViewport(`CT_${i}`);
if (!viewport) { allReady = false; break; }
const stackIdx = state.stackIdx?.[i];
const stackObj = state.stacks?.[stackIdx];
const importedImageIds = stackObj?.i || [];
if (!importedImageIds.length) continue;
try {
const viewportImageIds = viewport.getImageIds?.() || [];
if (
viewportImageIds.length !== importedImageIds.length ||
!viewportImageIds.every((id, idx) => id === importedImageIds[idx])
) {
console.warn(
`Viewport ${i}: importedImageIds do not match viewportImageIds`,
{ importedImageIds, viewportImageIds }
);
allReady = false;
break;
}
} catch (e) {
allReady = false;
}
}
if (!allReady && attempts < maxAttempts) {
attempts++;
setTimeout(() => { tryRestore(); }, 500);
return;
}
// Now safe to restore state for ALL viewports
for (let i = 0; i < numActive; i++) {
console.log("Restoring state for viewport", i);
const viewport = renderingEngine.getViewport(`CT_${i}`);
if (!viewport) continue;
const stackIdx = state.stackIdx?.[i];
const stackObj = state.stacks?.[stackIdx];
const importedImageIds = stackObj?.i || [];
if (!importedImageIds.length) continue;
console.log("mode", state.modes?.[i]);
if (state.modes?.[i] === "volume") {
// --- Restore volume ---
console.log("Restoring volume for viewport", i, importedImageIds);
const volumeId = `myVolumeId_${i}`;
const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: importedImageIds });
volume.load();
viewport.setVolumes([{ volumeId }]);
// --- Restore orientation if present ---
if (state.orientations && state.orientations[i] && typeof viewport.setOrientation === "function") {
try {
viewport.setOrientation(state.orientations[i]);
viewport.render();
} catch (e) {
console.warn("Failed to restore orientation for viewport", i, e);
}
}
if (
state.volumeSliceIndices
) {
console.log("Restoring volume slice index for viewport", i, state.volumeSliceIndices[i]);
setTimeout(() => {
csUtilities.jumpToSlice(viewport.element, { imageIndex: state.volumeSliceIndices[i] });
}, 100);
}
viewport.render();
} else {
// --- Restore stack ---
if (viewport.setStack) {
viewport.setStack(importedImageIds);
viewport.render();
}
// 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 (defensive colormap check)
if (
state.props &&
state.props[i] &&
typeof viewport.setProperties === "function"
) {
console.log("Restoring properties for viewport", i, state.props[i]);
const propsToRestore = { ...state.props[i] };
if (
propsToRestore.colormap !== undefined &&
propsToRestore.colormap !== null &&
typeof propsToRestore.colormap !== "string" &&
(typeof propsToRestore.colormap !== "object" || !("name" in propsToRestore.colormap))
) {
delete propsToRestore.colormap;
}
setTimeout(() => {
viewport.setProperties(propsToRestore);
viewport.render();
}, 100);
} else {
console.warn("No properties to restore for viewport", i);
}
// Restore camera
if (
state.cam &&
state.cam[i] &&
typeof viewport.setCamera === "function"
) {
console.log("Restoring camera for viewport", i, state.cam[i]);
viewport.setCamera(state.cam[i]);
viewport.render();
}
// --- Restore slice index if present ---
}
restoringStateRef.current = false;
}
// Delay start until after next paint
setTimeout(() => { tryRestore(); }, 100);
} 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<string, string> = {
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;
window[`resetViewer_${apiKey}`] = undefined;
window[`truncateStack_${apiKey}`] = undefined;
window[`getCurrentStackPosition_${apiKey}`] = undefined;
window[`loadAdditionalStack_${apiKey}`] = undefined;
window[`getViewport_${apiKey}`] = undefined;
window[`getAllViewports_${apiKey}`] = undefined;
};
}, [
viewportGrid,
viewportModes,
selectedStackIdx,
availableStacks,
viewportImageIds,
setViewportGrid,
setViewportModes,
setSelectedStackIdx,
setAvailableStacks,
setViewportImageIds,
setAvailableStacks,
setViewportImageIds,
setLoadedImageIdsAndCacheMeta,
]);
useEffect(() => {
const el = appRootRef.current;
if (!el) return;
function handleArrowKeyNavigation(e: KeyboardEvent) {
console.log("Handling arrow key navigation", e.key);
console.log("activeViewport", activeViewport);
if (activeViewport === null) return;
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) return;
const viewport = renderingEngine.getViewport(`CT_${activeViewport}`);
if (!viewport) return;
console.log(viewport)
// --- Stack mode navigation ---
if (viewportModes[activeViewport] === "stack") {
if (
typeof viewport.getCurrentImageIdIndex !== "function" ||
typeof viewport.setImageIdIndex !== "function"
) return;
const imageIds = viewportImageIds[activeViewport];
if (!imageIds || imageIds.length === 0) return;
if (e.key === "ArrowUp" || e.key === "ArrowRight") {
csUtilities.scroll(viewport, { delta: -1 });
e.preventDefault();
} else if (e.key === "ArrowDown" || e.key === "ArrowLeft") {
csUtilities.scroll(viewport, { delta: 1 });
e.preventDefault();
}
}
// --- Volume mode navigation ---
if (viewportModes[activeViewport] === "volume") {
// Try to scroll through slices in the current orientation
if (
typeof viewport.getSliceIndex === "function" &&
typeof viewport.getNumberOfSlices === "function"
) {
const numSlices = viewport.getNumberOfSlices();
if (numSlices > 1) {
if (e.key === "ArrowUp" || e.key === "ArrowRight") {
csUtilities.scroll(viewport, { delta: -1 });
e.preventDefault();
} else if (e.key === "ArrowDown" || e.key === "ArrowLeft") {
csUtilities.scroll(viewport, { delta: 1 });
e.preventDefault();
}
}
}
}
}
el.addEventListener("keydown", handleArrowKeyNavigation);
return () => {
el.removeEventListener("keydown", handleArrowKeyNavigation);
};
}, [activeViewport, viewportModes, viewportImageIds, updateOverlay]);
// 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 = [];
// 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(
<div
key={i}
ref={el => (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%",
boxShadow: activeViewport === i
? "0 0 0 4px #1976d2, 0 0 16px 4px #1976d2cc"
: undefined,
zIndex: activeViewport === i ? 10 : 1,
}}
onClick={() => {
setActiveViewport(i);
setViewportMenuOpen(false);
}}
onDoubleClick={() => handleDoubleClick(i)}
onDragOver={e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}}
onDrop={e => {
e.preventDefault();
const idxStr = e.dataTransfer.getData('text/stack-idx');
const stackIdx = parseInt(idxStr, 10);
if (!isNaN(stackIdx)) {
handleLoadStack(i, stackIdx);
}
}}
>
<button
style={{
position: "absolute",
top: 8,
left: 8,
zIndex: 20,
background: "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
padding: "4px 8px",
cursor: "pointer",
fontSize: "12px",
opacity: 0.85,
}}
onClick={() => {
setViewportModes(prev => {
const next = [...prev];
next[i] = prev[i] === 'stack' ? 'volume' : 'stack';
return next;
});
}}
>
{viewportModes[i] === 'stack' ? 'Volume' : 'Stack'}
</button>
{viewportModes[i] === 'volume' && (
<div
style={{
position: "absolute",
top: 8,
left: 70,
zIndex: 21,
display: "flex",
gap: "6px",
background: "rgba(34,34,34,0.85)",
borderRadius: "4px",
padding: "2px 6px",
alignItems: "center",
}}
>
<button
style={{
background: "#444",
color: "#fff",
border: "none",
borderRadius: "3px",
padding: "2px 8px",
cursor: "pointer",
fontSize: "12px",
marginRight: "2px",
}}
onClick={() => {
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
if (viewport && viewport.setOrientation) {
viewport.setOrientation("axial");
viewport.render();
}
}}
>
Axial
</button>
<button
style={{
background: "#444",
color: "#fff",
border: "none",
borderRadius: "3px",
padding: "2px 8px",
cursor: "pointer",
fontSize: "12px",
marginRight: "2px",
}}
onClick={() => {
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
if (viewport && viewport.setOrientation) {
viewport.setOrientation("sagittal");
viewport.render();
}
}}
>
Sagittal
</button>
<button
style={{
background: "#444",
color: "#fff",
border: "none",
borderRadius: "3px",
padding: "2px 8px",
cursor: "pointer",
fontSize: "12px",
}}
onClick={() => {
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
if (viewport && viewport.setOrientation) {
viewport.setOrientation("coronal");
viewport.render();
}
}}
>
Coronal
</button>
</div>
)}
{/* Fullscreen button below the Volume/Stack toggle */}
<button
style={{
position: "absolute",
top: 40,
left: 8,
zIndex: 20,
background: "#111",
color: "#fff",
border: "none",
borderRadius: "4px",
padding: "4px 8px",
cursor: "pointer",
fontSize: "13px",
opacity: 0.85,
marginTop: "4px",
display: "flex",
alignItems: "center",
gap: "6px",
}}
title="Toggle Fullscreen"
onClick={e => {
e.stopPropagation();
const el = viewportRefs.current[i];
if (document.fullscreenElement === el) {
document.exitFullscreen();
} else if (el?.requestFullscreen) {
el.requestFullscreen();
} else if ((el as any)?.webkitRequestFullscreen) {
(el as any).webkitRequestFullscreen();
} else if ((el as any)?.msRequestFullscreen) {
(el as any).msRequestFullscreen();
}
}}
onMouseEnter={() => setFullscreenHoverIdx(i)}
onMouseLeave={() => setFullscreenHoverIdx(null)}
>
<span style={{ fontSize: "18px", lineHeight: 1 }}></span>
{fullscreenHoverIdx === i && (
<span style={{ marginLeft: 6 }}>
{document.fullscreenElement === viewportRefs.current[i]
? "Exit Fullscreen"
: "Fullscreen"}
</span>
)}
</button>
{viewportModes[i] === "stack" && annotationNavEnabled && (() => {
// 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 (
<div
style={{
position: "absolute",
left: "50%",
bottom: 60,
transform: "translateX(-50%)",
zIndex: 30,
display: "flex",
gap: 4,
pointerEvents: "auto",
alignItems: "center",
}}
>
{/* Prev/Next buttons ... */}
<button
style={{
background: "rgba(30,30,30,0.7)",
color: "#fff",
border: "none",
borderRadius: "50%",
width: 28,
height: 28,
fontSize: "16px",
fontWeight: "bold",
cursor: "pointer",
opacity: 0.7,
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 1px 4px rgba(0,0,0,0.18)",
transition: "opacity 0.2s",
}}
title="Previous Annotation"
aria-label="Previous Annotation"
onClick={() => {
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
if (!viewport || typeof viewport.getCurrentImageIdIndex !== "function") return;
const currentIdx = viewport.getCurrentImageIdIndex();
const prevIdx = findNextAnnotationIdx(currentIdx, imageIds, stackAnnotations, -1);
if (prevIdx !== null && typeof viewport.setImageIdIndex === "function") {
csUtilities.jumpToSlice(viewport.element, { imageIndex: prevIdx });
updateOverlay(i, viewport);
// Select the first annotation on the new image
const newImageId = imageIds[prevIdx];
const annsOnImage = stackAnnotations.filter(
ann => ann.metadata.referencedImageId === newImageId
);
handleSelectAnnotation(i, annsOnImage.length > 0 ? annsOnImage[0].annotationUID : null);
}
}}
>
<span aria-hidden="true"></span>
</button>
<button
style={{
background: "rgba(30,30,30,0.7)",
color: "#fff",
border: "none",
borderRadius: "50%",
width: 28,
height: 28,
fontSize: "16px",
fontWeight: "bold",
cursor: "pointer",
opacity: 0.7,
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 1px 4px rgba(0,0,0,0.18)",
transition: "opacity 0.2s",
}}
title="Next Annotation"
aria-label="Next Annotation"
onClick={() => {
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
if (!viewport || typeof viewport.getCurrentImageIdIndex !== "function") return;
const currentIdx = viewport.getCurrentImageIdIndex();
const nextIdx = findNextAnnotationIdx(currentIdx, imageIds, stackAnnotations, 1);
if (nextIdx !== null && typeof viewport.setImageIdIndex === "function") {
csUtilities.jumpToSlice(viewport.element, { imageIndex: nextIdx });
updateOverlay(i, viewport);
// Select the first annotation on the new image
const newImageId = imageIds[nextIdx];
const annsOnImage = stackAnnotations.filter(
ann => ann.metadata.referencedImageId === newImageId
);
handleSelectAnnotation(i, annsOnImage.length > 0 ? annsOnImage[0].annotationUID : null);
}
}}
>
<span aria-hidden="true"></span>
</button>
{/* List and select/delete individual annotations */}
{currentAnnotations.length > 0 && (
<div style={{ display: "flex", gap: 2, alignItems: "center" }}>
{currentAnnotations.map(ann => (
<div
key={ann.annotationUID}
style={{
display: "flex",
alignItems: "center",
background: selectedAnnUID === ann.annotationUID ? "rgb(4, 225, 0)" : "#444",
color: selectedAnnUID === ann.annotationUID ? "#222" : "#fff",
borderRadius: "4px",
padding: "2px 6px",
marginRight: 2,
cursor: "pointer",
border: selectedAnnUID === ann.annotationUID ? "2px solid #1976d2" : "none",
fontWeight: selectedAnnUID === ann.annotationUID ? "bold" : "normal",
}}
onClick={() => handleSelectAnnotation(i, ann.annotationUID)}
>
<span style={{ marginRight: 4 }}>
{selectedAnnUID === ann.annotationUID ? "★ " : ""}
{ann.metadata.toolName || "Annotation"}
</span>
<button
style={{
background: "#b71c1c",
color: "#fff",
border: "none",
borderRadius: "50%",
width: 20,
height: 20,
fontSize: "13px",
fontWeight: "bold",
cursor: "pointer",
marginLeft: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: 0.85,
}}
title="Delete this annotation"
aria-label="Delete annotation"
onClick={e => {
e.stopPropagation();
handleDeleteAnnotation(i, ann.annotationUID, currentAnnotations);
}}
>
🗑
</button>
</div>
))}
</div>
)}
</div>
);
})()}
{/* Overlay info (bottom left of viewport) */}
<div
style={{
position: "absolute",
left: 8,
bottom: 8,
background: "rgba(0,0,0,0.7)",
color: "#fff",
padding: "6px 12px",
borderRadius: "4px",
fontSize: "14px",
zIndex: 10,
pointerEvents: "none",
minWidth: "120px",
userSelect: "none",
}}
>
{viewportModes[i] === "stack" ? (
<>
<div>
Image: {imageInfos[i]?.index} / {imageInfos[i]?.total}
</div>
<div>
WC: {imageInfos[i]?.windowCenter} &nbsp; WW: {imageInfos[i]?.windowWidth}
</div>
</>
) : (
<div>
Slice: {imageInfos[i]?.index} / {imageInfos[i]?.total}
{imageInfos[i]?.slicePosition && (
<span> &nbsp; ({imageInfos[i]?.slicePosition})</span>
)}
<br />
WC: {imageInfos[i]?.windowCenter} &nbsp; WW: {imageInfos[i]?.windowWidth}
</div>
)}
</div>
{/* Preset menu (bottom right of viewport) */}
<div
style={{
position: "absolute",
right: 8,
bottom: 8,
zIndex: 10,
pointerEvents: "auto", // <-- ensure pointer events are enabled
}}
>
<div
className="preset-menu"
style={{
background: "rgba(0,0,0,0.7)",
color: "#fff",
borderRadius: "4px",
fontSize: "14px",
minWidth: "120px",
overflow: "hidden",
cursor: "pointer",
position: "relative",
boxShadow: presetsOpenArr[i] ? "0 2px 8px rgba(0,0,0,0.3)" : undefined,
pointerEvents: "auto", // <-- ensure pointer events are enabled
}}
onClick={e => e.stopPropagation()} // <-- prevent bubbling to viewport
>
<div
style={{
padding: "6px 12px",
fontWeight: "bold",
userSelect: "none",
}}
onClick={e => {
e.stopPropagation(); // <-- prevent bubbling to viewport
setPresetsOpenArr(prev => {
const next = [...prev];
next[i] = !next[i];
return next;
});
}}
>
Presets
<span style={{ float: "right", fontWeight: "normal" }}>
{presetsOpenArr[i] ? "▲" : "▼"}
</span>
</div>
{presetsOpenArr[i] && (
<div
className="preset-menu-content"
style={{
background: "rgba(0,0,0,0.95)",
borderRadius: "0 0 4px 4px",
padding: "6px 0 6px 0",
display: "flex",
flexDirection: "column",
zIndex: 11,
pointerEvents: "auto", // <-- ensure pointer events are enabled
}}
onClick={e => e.stopPropagation()} // <-- prevent bubbling to viewport
>
{WINDOW_LEVEL_PRESETS.map((preset) => (
<button
key={preset.name}
style={{
display: "block",
width: "100%",
margin: "2px 0",
background: "#333",
color: "#fff",
border: "none",
borderRadius: "3px",
cursor: "pointer",
padding: "4px 0",
fontSize: "13px",
opacity: 0.9,
}}
onClick={e => {
e.stopPropagation(); // <-- prevent bubbling to viewport
handleApplyPreset(i, preset.center, preset.width);
setPresetsOpenArr(prev => {
const next = [...prev];
next[i] = false;
return next;
});
}}
>
{preset.name}
</button>
))}
</div>
)}
</div>
</div>
{/* Position bar inside each viewport */}
{viewportModes[i] === "stack" && viewportImageIds[i] && (
<div
style={{
position: "absolute",
top: "10%",
right: 8,
width: "12px",
height: "80%",
maxHeight: "100%",
minHeight: 0,
zIndex: 30,
display: viewportImageIds[i].length > 0 ? "flex" : "none",
alignItems: "flex-start",
justifyContent: "center",
pointerEvents: "none",
flexDirection: "column",
padding: 0,
}}
>
<div
style={{
width: "8px",
height: "100%",
maxHeight: "100%",
minHeight: 0,
borderRadius: "6px",
background: "#222",
position: "relative",
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
boxShadow: "0 0 8px #2196f3cc",
overflow: "hidden",
}}
>
{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 (
<div
key={id}
style={{
width: "100%",
height: `${100 / viewportImageIds[i].length}%`,
background: isCurrent
? "linear-gradient(to right, #ffeb3b 60%, #ff9800 100%)"
: loaded
? "linear-gradient(to right, #4caf50 60%, #2196f3 100%)"
: "#444",
opacity: loaded ? 0.95 : 0.4,
transition: "background 0.3s, opacity 0.3s",
borderBottom:
idx < viewportImageIds[i].length - 1
? "1px solid #222"
: "none",
pointerEvents: "auto",
cursor: "pointer",
}}
title={
isCurrent
? `Current image #${idx + 1}`
: loaded
? `Go to image #${idx + 1}`
: `Loading...`
}
onClick={() => {
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);
}
}}
/>
);
})}
</div>
</div>
)}
{viewportModes[i] === "volume" && (() => {
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
let numSlices = 0;
let currentSlice = 0;
if (viewport && typeof viewport.getNumberOfSlices === "function" && typeof viewport.getSliceIndex === "function") {
numSlices = viewport.getNumberOfSlices();
currentSlice = viewport.getSliceIndex();
}
if (numSlices > 1) {
return (
<div
style={{
position: "absolute",
top: "10%",
right: 8,
width: "12px",
height: "80%",
maxHeight: "100%",
minHeight: 0,
zIndex: 30,
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
pointerEvents: "auto", // Enable pointer events for click
flexDirection: "column",
padding: 0,
}}
>
<div
style={{
width: "8px",
height: "100%",
maxHeight: "100%",
minHeight: 0,
borderRadius: "6px",
background: "#222",
position: "relative",
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
boxShadow: "0 0 8px #2196f3cc",
overflow: "hidden",
cursor: "pointer",
}}
onClick={e => {
// Calculate the clicked slice index
const bar = e.currentTarget as HTMLDivElement;
const rect = bar.getBoundingClientRect();
const y = e.clientY - rect.top;
const sliceIdx = Math.floor((y / rect.height) * numSlices);
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
if (
viewport
) {
csUtilities.jumpToSlice(viewport.element, { imageIndex: sliceIdx });
viewport.render();
updateOverlay(i, viewport);
}
}}
>
{Array.from({ length: numSlices }).map((_, idx) => {
const isCurrent = idx === currentSlice;
return (
<div
key={idx}
style={{
width: "100%",
height: `${100 / numSlices}%`,
minHeight: isCurrent ? 1 : 0,
background: isCurrent
? "linear-gradient(to right, #ffeb3b 60%, #ff9800 100%)"
: "#444",
opacity: isCurrent ? 0.95 : 0.4,
transition: "background 0.3s, opacity 0.3s",
borderBottom:
idx < numSlices - 1
? "1px solid #222"
: "none",
pointerEvents: "none", // Prevent child from blocking parent click
cursor: "pointer",
}}
title={
isCurrent
? `Current slice #${idx + 1}`
: `Go to slice #${idx + 1}`
}
/>
);
})}
</div>
</div>
);
}
return null;
})()}
{isVisible && (availableStacks.length > 1 || !viewportImageIds[i] || viewportImageIds[i].length === 0) && (
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 8,
zIndex: 20,
display: "flex",
alignItems: "center",
justifyContent: "center", // Center horizontally
pointerEvents: "auto",
width: "100%",
// Remove background/gap if you want only the selector to be visible
background: "none",
borderRadius: "0",
padding: 0,
}}
>
{/* Custom Stack Dropdown */}
<div style={{ position: "relative" }}>
{/* Stack selector trigger button */}
<button
onClick={(e) => {
e.stopPropagation();
setOpenDropdownIdx(prev => (prev === i ? null : i));
}}
style={{
padding: "6px 12px",
background: "#333",
color: "#fff",
border: "1px solid #444",
borderRadius: "4px",
cursor: "pointer",
pointerEvents: "auto",
fontSize: 13,
}}
title="Choose stack"
>
{(() => {
// Determine label for currently selected stack in this viewport
const selIdx = (Array.isArray(selectedStackIdx) ? selectedStackIdx[i] : undefined);
const stackObj = typeof selIdx === "number" && availableStacks[selIdx] ? availableStacks[selIdx] : undefined;
const imageIds = stackObj && Array.isArray((stackObj as any).imageIds) ? (stackObj as any).imageIds : [];
const label = stackObj
? ((stackObj as any).name || ((stackObj as any).caseId ? `Case: ${(stackObj as any).caseId}` : undefined))
: undefined;
if (label) return label;
if (imageIds.length > 0) return `Stack ${(typeof selIdx === 'number') ? (selIdx + 1) : '1'}`;
// fallback: if viewport already has images, show count
const currentImages = viewportImageIds[i] || [];
if (currentImages.length > 0) return `Stack (${currentImages.length} images)`;
return "No stack";
})()}
<span style={{ marginLeft: 8, opacity: 0.8 }}>{openDropdownIdx === i ? "▲" : "▼"}</span>
</button>
{/* Dropdown */}
{openDropdownIdx === i && (
<div
ref={el => {
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: 220,
maxHeight: 260,
overflowY: "auto",
overscrollBehavior: "contain",
scrollbarWidth: "thin",
padding: 6,
}}
role="listbox"
onMouseDown={e => e.stopPropagation()}
onWheel={e => { /* trap handled elsewhere */ }}
onBlur={e => {
setTimeout(() => {
if (!(e.currentTarget as HTMLElement).contains(document.activeElement)) {
setOpenDropdownIdx(null);
}
}, 0);
}}
>
{/* Group stacks by studyId (use 'Unspecified' for missing) */}
{(() => {
const groups: Record<string, { idx: number; stack: any }[]> = {};
availableStacks.forEach((stackObj, idx) => {
const studyKey = (stackObj as any).studyId || (stackObj as any).studyInstanceUID || "Unspecified";
if (!groups[studyKey]) groups[studyKey] = [];
groups[studyKey].push({ idx, stack: stackObj });
});
return Object.keys(groups).map(studyKey => (
<div key={studyKey} style={{ marginBottom: 6 }}>
<div style={{ fontSize: 12, color: "#aaa", padding: "4px 8px", fontWeight: 700 }}>
{studyKey === "Unspecified" ? "Unspecified study" : `Study: ${studyKey}`}
</div>
<div>
{groups[studyKey].map(({ idx, stack }) => {
const imageIds = Array.isArray((stack as any).imageIds) ? (stack as any).imageIds : [];
const label = (stack && ((stack as any).name || (stack as any).caseId))
? ((stack as any).name || `Case: ${(stack as any).caseId}`)
: `Stack ${idx + 1}`;
const count = imageIds.length;
const invalid = !Array.isArray(imageIds) || count === 0;
return (
<button
key={idx}
role="option"
aria-selected={false}
title={invalid ? `Invalid stack descriptor` : `${label}${count} image${count === 1 ? '' : 's'}${(stack as any)?.caseId ? ` — case ${(stack as any).caseId}` : ''}`}
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
width: "100%",
padding: "8px 12px",
background: "transparent",
color: "#fff",
border: "none",
textAlign: "left",
cursor: invalid ? "not-allowed" : "pointer",
}}
onClick={(e) => {
e.stopPropagation();
if (invalid) {
console.warn(`Attempted to select invalid stack at index ${idx}`, stack);
return;
}
handleLoadStack(i, idx).catch(err => console.error("Failed to load stack:", err));
setOpenDropdownIdx(null);
}}
>
<div style={{ fontWeight: "600", fontSize: 13 }}>{label}</div>
<div style={{ fontSize: 12, opacity: 0.7 }}>
{count} image{count === 1 ? '' : 's'}{(stack as any)?.caseId ? ` • case ${(stack as any).caseId}` : ''}
</div>
</button>
);
})}
</div>
</div>
));
})()}
</div>
)}
</div>
</div>
)
}
</div >
);
}
return (
<div
ref={appRootRef}
style={{
position: "relative",
inset: 0,
width: "100%",
height: "100%",
boxSizing: "border-box",
background: "#222",
padding: "12px",
overflow: "hidden",
}}>
{/* Loading overlay */}
{loadingState && (
<div
style={{
position: "absolute",
zIndex: 3000,
top: 0,
left: 0,
width: "100%",
height: "100%",
background: "rgba(0,0,0,0.7)",
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 28,
fontWeight: "bold",
letterSpacing: 1,
}}
>
{loadingState === "annotations"
? "Loading Annotations..."
: "Loading Viewer State..."}
</div>
)}
{/* Top bar stack browser */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
minHeight: 54,
background: "#23272bE6", // slightly transparent for layering
color: "#fff",
zIndex: 1001, // higher than most overlays but below modals
display: "flex",
alignItems: "center",
borderBottom: "2px solid #444",
padding: "0 16px",
gap: 18,
overflowX: "auto",
userSelect: "none",
pointerEvents: "auto",
}}
>
{/* Group stacks by studyId or studyInstanceUID or 'Unspecified' */}
{(() => {
const groups: Record<string, { idx: number; stack: StackEntry }[]> = {};
availableStacks.forEach((stackObj, idx) => {
const studyKey = stackObj.studyId || stackObj.studyInstanceUID || 'Unspecified';
if (!groups[studyKey]) groups[studyKey] = [];
groups[studyKey].push({ idx, stack: stackObj });
});
return Object.keys(groups).map(studyKey => (
<div key={studyKey} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: '#aaa', fontSize: 13, fontWeight: 700, marginRight: 6 }}>
{studyKey === 'Unspecified' ? 'Unspecified study' : `Study: ${studyKey}`}
</span>
{groups[studyKey].map(({ idx, stack }) => {
const imageIds = Array.isArray(stack.imageIds) ? stack.imageIds : [];
const label = stack.name || (stack.caseId ? `Case: ${stack.caseId}` : `Stack ${idx + 1}`);
const count = imageIds.length;
const invalid = !Array.isArray(imageIds) || count === 0;
// Highlight if this stack is selected in any viewport
const isSelected = selectedStackIdx.includes(idx);
return (
<div
key={idx}
draggable={!invalid}
onDragStart={e => {
if (invalid) return;
e.dataTransfer.setData('text/stack-idx', String(idx));
e.dataTransfer.effectAllowed = 'copy';
}}
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
minWidth: 90,
maxWidth: 220,
padding: '7px 14px',
margin: '0 2px',
background: isSelected ? '#1976d2' : '#333',
color: isSelected ? '#fff' : '#eee',
border: isSelected ? '2px solid #90caf9' : 'none',
borderRadius: '4px',
fontWeight: isSelected ? 'bold' : 'normal',
fontSize: 15,
cursor: invalid ? 'not-allowed' : 'grab',
opacity: invalid ? 0.5 : 1,
transition: 'background 0.2s, border 0.2s',
boxShadow: isSelected ? '0 2px 8px #1976d2aa' : undefined,
}}
title={invalid ? 'Invalid stack descriptor' : `${label}${count} image${count === 1 ? '' : 's'}`}
>
<span>{label}</span>
<span style={{ fontSize: 12, opacity: 0.7, marginLeft: 8 }}>{count} img{count === 1 ? '' : 's'}</span>
</div>
);
})}
</div>
));
})()}
</div>
<div>
{/* Side menu toggle button */}
<button
style={{
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
left: sideMenuOpen ? 250 : -20, // Slide right when open
zIndex: 200,
width: 40,
height: 40,
background: "#222",
color: "#fff",
border: "none",
borderRadius: "50%",
fontWeight: "bold",
cursor: "pointer",
fontSize: "22px",
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
transition: "left 0.25s cubic-bezier(.4,2,.6,1), background 0.2s",
}}
onClick={() => setSideMenuOpen(open => !open)}
aria-label="Open menu"
>
</button>
{/* Side menu drawer */}
{sideMenuOpen && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: 220,
height: "100%",
background: "#222",
color: "#fff",
zIndex: 199,
boxShadow: "2px 0 12px rgba(0,0,0,0.3)",
flexDirection: "column",
padding: "24px 16px 16px 16px",
display: "flex",
overflow: "hidden",
}}
>
<div
style={{
flex: 1,
minHeight: 0,
overflowY: "auto", // <-- enables scrolling for menu content
display: "flex",
flexDirection: "column",
}}
>
<div style={{ fontWeight: "bold", fontSize: 18, marginBottom: 24 }}>
Menu
<button
style={{
float: "right",
background: "transparent",
color: "#fff",
border: "none",
fontSize: "22px",
cursor: "pointer",
}}
onClick={() => setSideMenuOpen(false)}
aria-label="Close menu"
>
×
</button>
</div>
<button
style={{
padding: "10px 18px",
background: "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginBottom: "18px",
width: "100%",
}}
onClick={() => fileInputRef.current?.click()}
>
Load DICOM Folder
</button>
<input
ref={fileInputRef}
type="file"
style={{ display: "none" }}
multiple
onChange={handleFilesSelected}
accept=".dcm,application/dicom"
/>
<div
style={{
marginBottom: "18px",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<input
type="checkbox"
id="orderBySliceLocation"
checked={orderBySliceLocation}
onChange={e => setOrderBySliceLocation(e.target.checked)}
style={{ accentColor: "#666" }}
/>
<label htmlFor="orderBySliceLocation" style={{ fontSize: "15px", cursor: "pointer" }}>
Order by Slice Location
</label>
</div>
<button
style={{
padding: "10px 18px",
background: "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginBottom: "18px",
width: "100%",
}}
onClick={handleShowMetadata}
>
Show DICOM Metadata
</button>
<button
style={{
padding: "10px 18px",
background: crossHairsEnabled ? "#1976d2" : "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginBottom: "18px",
width: "100%",
}}
onClick={() => setCrossHairsEnabled((prev) => !prev)}
>
{crossHairsEnabled ? "Disable" : "Enable"} Cross Reference Lines
</button>
<button
style={{
padding: "10px 18px",
background: referenceLinesEnabled ? "#1976d2" : "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginBottom: "18px",
width: "100%",
}}
onClick={() => setReferenceLinesEnabled((prev) => !prev)}
>
{referenceLinesEnabled ? "Disable" : "Enable"} Reference Lines
</button>
<button
onClick={sortViewportImageIdsBySliceLocation}
style={{ margin: 8, padding: 8 }}
>
Sort All Viewports by Slice Location
</button>
<button
style={{
padding: "10px 18px",
background: altEnablesReferenceCursors ? "#1976d2" : "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginBottom: "18px",
width: "100%",
}}
onClick={() => setAltEnablesReferenceCursors((prev) => !prev)}
>
{altEnablesReferenceCursors
? "Disable Alt Key for Reference Cursors"
: "Enable Alt Key for Reference Cursors"}
</button>
<button
style={{
padding: "10px 18px",
background: annotationNavEnabled ? "#1976d2" : "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginBottom: "18px",
width: "100%",
}}
onClick={() => setAnnotationNavEnabled((prev) => !prev)}
>
{annotationNavEnabled ? "Hide" : "Show"} Annotation Navigation
</button>
{/* Add more menu items here if needed */}
</div>
</div>
)}
</div>
{/* Viewport grid menu at top center */}
<div
className="grid-menu-hover-container"
style={{
position: "absolute",
top: 0,
left: "50%",
transform: "translateX(-50%)",
zIndex: 100,
display: "flex",
flexDirection: "column",
alignItems: "center",
width: "auto",
height: "48px",
pointerEvents: "none", // Only the hit area and button will be interactive
}}
>
{/* Transparent hit area to catch hover/focus */}
<div
style={{
position: "absolute",
top: 0,
left: "50%",
transform: "translateX(-50%)",
width: 80,
height: 14,
zIndex: 1,
pointerEvents: "auto",
background: "transparent",
}}
onMouseEnter={() => setGridBtnActive(true)}
onFocus={() => setGridBtnActive(true)}
tabIndex={-1}
/>
{/* --- Fullscreen button next to grid button --- */}
<button
style={{
position: "relative",
top: gridBtnActive ? "0" : "-22px",
opacity: gridBtnActive ? 1 : 0.5,
transition: "top 0.25s cubic-bezier(.4,2,.6,1), opacity 0.2s",
padding: "6px 16px",
background: "#222",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginLeft: "4px",
marginRight: "8px",
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
pointerEvents: gridBtnActive ? "auto" : "none",
zIndex: 2,
display: gridBtnActive ? "block" : "none",
}}
title="Toggle Fullscreen"
onClick={() => {
const root = document.querySelector(".dicom-viewer-root") as HTMLElement;
if (!root) return;
if (document.fullscreenElement === root) {
document.exitFullscreen();
} else if (root.requestFullscreen) {
root.requestFullscreen();
} else if ((root as any).webkitRequestFullscreen) {
(root as any).webkitRequestFullscreen();
} else if ((root as any).msRequestFullscreen) {
(root as any).msRequestFullscreen();
}
}}
>
Fullscreen
</button>
<button
className="grid-menu-btn"
style={{
position: "relative",
top: gridBtnActive ? "0" : "-22px",
opacity: gridBtnActive ? 1 : 0.5,
transition: "top 0.25s cubic-bezier(.4,2,.6,1), opacity 0.2s",
padding: "6px 16px",
background: "#222",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginRight: "8px",
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
pointerEvents: gridBtnActive ? "auto" : "none", // Only clickable when active
zIndex: 2,
display: gridBtnActive ? "block" : "none",
}}
onMouseLeave={() => setGridBtnActive(false)}
onBlur={() => setGridBtnActive(false)}
onClick={() => setViewportMenuOpen(open => !open)}
tabIndex={0}
>
Grid: {viewportGrid.rows}x{viewportGrid.cols}
</button>
{viewportMenuOpen && (
<div
style={{
position: "absolute",
top: "110%",
left: "50%",
transform: "translateX(-50%)",
background: "#222",
color: "#fff",
borderRadius: "6px",
boxShadow: "0 2px 12px rgba(0,0,0,0.4)",
padding: "12px",
display: "grid",
gridTemplateColumns: "repeat(3, 40px)",
gap: "8px",
zIndex: 200,
pointerEvents: "auto",
}}
>
{gridOptions.map(opt => (
<button
key={`${opt.rows}x${opt.cols}`}
style={{
width: "40px",
height: "40px",
background: (opt.rows === viewportGrid.rows && opt.cols === viewportGrid.cols) ? "#444" : "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
}}
onClick={() => {
saveVisibleViewportState();
setViewportGrid({ rows: opt.rows, cols: opt.cols })
setViewportMenuOpen(false)
}}
>
{opt.rows}x{opt.cols}
</button>
))}
</div>
)}
</div>
{/* Viewport grid (inside main div) */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
display: "grid",
margin: 3,
gridTemplateRows: `repeat(${viewportGrid.rows}, 1fr)`,
gridTemplateColumns: `repeat(${viewportGrid.cols}, 1fr)`,
gap: "5px", // Increase gap for better visibility
zIndex: 1,
}}
>
{viewports}
</div>
{/* Settings button at top right */}
<button
style={{
position: "absolute",
top: 8,
right: 8,
zIndex: 20,
padding: "6px 12px",
background: "#222",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
opacity: 0.85,
userSelect: "none",
}}
onClick={() => setSettingsOpen(true)}
>
Settings
</button>
{/* Settings Modal (viewport-local, not fixed to page) */}
{
settingsOpen && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
background: "rgba(0,0,0,0.4)",
zIndex: 1000,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => setSettingsOpen(false)}
>
<div
style={{
background: "#222",
color: "#fff",
borderRadius: "8px",
minWidth: "320px",
minHeight: "180px",
padding: "24px",
boxShadow: "0 4px 24px rgba(0,0,0,0.4)",
position: "relative",
}}
onClick={e => e.stopPropagation()}
>
<div style={{ fontSize: "20px", marginBottom: "16px" }}>
Settings
</div>
{/* Settings content: Tool dropdowns */}
<div style={{ marginBottom: "24px" }}>
<div style={{ marginBottom: 12, fontWeight: "bold" }}>Mouse Button Tool Bindings</div>
{MOUSE_BUTTONS.map((btn) => (
<div key={btn.value} style={{ marginBottom: 10 }}>
<label style={{ marginRight: 8 }}>{btn.label} Button:</label>
<select
value={mouseToolBindings[btn.value]}
onChange={e => setMouseToolBindings(prev => ({ ...prev, [btn.value]: e.target.value }))}
style={{
background: "#333",
color: "#fff",
border: "1px solid #444",
borderRadius: "4px",
padding: "4px 8px",
fontSize: "14px",
}}
>
{TOOL_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
))}
{/* Expandable advanced mouse bindings */}
<div style={{ margin: "12px 0" }}>
<button
style={{
background: "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
padding: "4px 12px",
cursor: "pointer",
fontSize: "14px",
marginBottom: "8px",
}}
onClick={() => setShowAdvancedMouseBindings(v => !v)}
>
{showAdvancedMouseBindings ? "Hide" : "Show"} Advanced Mouse Bindings
</button>
{showAdvancedMouseBindings && (
<div style={{ marginTop: 8 }}>
{ALL_MOUSE_BINDINGS.slice(3).map((btn) => (
<div key={btn.value} style={{ marginBottom: 10 }}>
<label style={{ marginRight: 8 }}>{btn.label}:</label>
<select
value={mouseToolBindings[btn.value] || ""}
onChange={e => setMouseToolBindings(prev => ({ ...prev, [btn.value]: e.target.value }))}
style={{
background: "#333",
color: "#fff",
border: "1px solid #444",
borderRadius: "4px",
padding: "4px 8px",
fontSize: "14px",
}}
>
<option value="">(None)</option>
{TOOL_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
))}
</div>
)}
</div>
{/* Ctrl + Mouse Button Tool Bindings */}
<div style={{ margin: "18px 0 8px 0", fontWeight: "bold" }}>Ctrl + Mouse Button Tool Bindings</div>
{MOUSE_BUTTONS.map((btn) => (
<div key={btn.value + "_ctrl"} style={{ marginBottom: 10 }}>
<label style={{ marginRight: 8 }}>{btn.label} + Ctrl:</label>
<select
value={ctrlMouseToolBindings[btn.value] || ""}
onChange={e => setCtrlMouseToolBindings(prev => ({ ...prev, [btn.value]: e.target.value }))}
style={{
background: "#333",
color: "#fff",
border: "1px solid #444",
borderRadius: "4px",
padding: "4px 8px",
fontSize: "14px",
}}
>
<option value="">(None)</option>
{TOOL_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
))}
{/* Separate button for advanced Ctrl+Mouse bindings */}
<div style={{ margin: "12px 0" }}>
<button
style={{
background: "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
padding: "4px 12px",
cursor: "pointer",
fontSize: "14px",
marginBottom: "8px",
}}
onClick={() => setShowAdvancedCtrlMouseBindings(v => !v)}
>
{showAdvancedCtrlMouseBindings ? "Hide" : "Show"} Advanced Ctrl + Mouse Bindings
</button>
{showAdvancedCtrlMouseBindings && (
<div style={{ marginTop: 8 }}>
{ALL_MOUSE_BINDINGS.slice(3).map((btn) => (
<div key={btn.value + "_ctrl"} style={{ marginBottom: 10 }}>
<label style={{ marginRight: 8 }}>{btn.label} + Ctrl:</label>
<select
value={ctrlMouseToolBindings[btn.value] || ""}
onChange={e => setCtrlMouseToolBindings(prev => ({ ...prev, [btn.value]: e.target.value }))}
style={{
background: "#333",
color: "#fff",
border: "1px solid #444",
borderRadius: "4px",
padding: "4px 8px",
fontSize: "14px",
}}
>
<option value="">(None)</option>
{TOOL_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
))}
</div>
)}
</div>
</div>
<button
style={{
position: "absolute",
top: 12,
right: 12,
background: "transparent",
color: "#fff",
border: "none",
fontSize: "20px",
cursor: "pointer",
}}
onClick={() => setSettingsOpen(false)}
aria-label="Close"
>
×
</button>
</div>
</div>
)
}
{
metaModalOpen && (
<div
style={{
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
background: "rgba(0,0,0,0.5)",
zIndex: 2000,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => setMetaModalOpen(false)}
>
<div
style={{
background: "#222",
color: "#fff",
borderRadius: "8px",
minWidth: "320px",
maxWidth: "80vw",
maxHeight: "80vh",
padding: "24px",
boxShadow: "0 4px 24px rgba(0,0,0,0.4)",
position: "relative",
overflow: "auto",
}}
onClick={e => e.stopPropagation()}
>
<div style={{ fontSize: "20px", marginBottom: "16px" }}>
DICOM Metadata
</div>
{metaModalContent}
<button
style={{
position: "absolute",
top: 12,
right: 12,
background: "transparent",
color: "#fff",
border: "none",
fontSize: "20px",
cursor: "pointer",
}}
onClick={() => setMetaModalOpen(false)}
aria-label="Close"
>
×
</button>
</div>
</div>
)
}
{/* ...rest of your UI... */}
</div >
)
}
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
}
}