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

5579 lines
214 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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,
imageLoader,
utilities as csUtilities,
} from "@cornerstonejs/core"
import { init as csRenderInit } 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;
};
type ViewportDropZone = "center" | "left" | "right" | "top" | "bottom";
// 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;
// CT-specific presets. Other modalities use reset + autolevel only.
const CT_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 },
]
type PresetOption =
| { name: string; action: "reset" | "autolevel" }
| { name: string; action: "preset"; center: number; width: number };
const getPresetOptionsForModality = (modality?: string): PresetOption[] => {
const normalized = (modality || "").toUpperCase();
const common: PresetOption[] = [
{ name: "Reset to default", action: "reset" },
{ name: "Auto level", action: "autolevel" },
];
if (normalized !== "CT") {
return common;
}
return [
...common,
...CT_WINDOW_LEVEL_PRESETS.map((preset) => ({
name: preset.name,
action: "preset" as const,
center: preset.center,
width: preset.width,
})),
];
}
// Tool options for dropdowns (icons for compact UI)
const TOOL_OPTIONS = [
{ label: "Window/Level", value: WindowLevelTool.toolName, icon: "🌓" },
{ label: "Pan", value: PanTool.toolName, icon: "✋" },
{ label: "Zoom", value: ZoomTool.toolName, icon: "🔍" },
{ label: "Stack Scroll", value: StackScrollTool.toolName, icon: "📚" },
{ label: "Rotate", value: PlanarRotateTool.toolName, icon: "↺" },
{ label: "Length", value: LengthTool.toolName, icon: "📏" },
{ label: "Probe", value: ProbeTool.toolName, icon: "🔎" },
{ label: "Arrow (No Label)", value: NoLabelArrowAnnotateTool.toolName, icon: "➡️" },
{ label: "Arrow Annotate", value: ArrowAnnotateTool.toolName, icon: "✈️" },
{ label: "Rectangle ROI", value: RectangleROITool.toolName, icon: "▭" },
{ label: "Ellipse ROI", value: EllipticalROITool.toolName, icon: "◯" },
{ label: "Freehand ROI", value: PlanarFreehandROITool.toolName, icon: "✏️" },
{ label: "Freehand Contour Segmentation", value: PlanarFreehandContourSegmentationTool.toolName, icon: "🖊️" },
{ label: "Sculptor", value: SculptorTool.toolName, icon: "🔧" },
{ label: "Reference Cursors", value: ReferenceCursors.toolName, icon: "⦿" },
];
function getToolIcon(toolName?: string) {
if (!toolName) return '🔖';
const found = TOOL_OPTIONS.find(o => o.value === toolName || o.label === toolName);
return found?.icon || '🔖';
}
function stripWadouriPrefix(imageId?: string): string {
if (!imageId) return "";
if (!imageId.startsWith("wadouri:")) return imageId;
return imageId.replace(/^wadouri:+/, "").replace(/^:+/, "");
}
// 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";
const [settingsOpen, setSettingsOpen] = useState(false);
// Settings open state
// (top bar removed; stack browser moved to right-side panel)
// Right-side stack panel open state (toggled by button)
const [stackPanelOpen, setStackPanelOpen] = useState(false);
const [stackPanelWidth, setStackPanelWidth] = useState(320);
const stackPanelResizeRef = useRef<{ resizing: boolean; startX: number; startWidth: number }>({
resizing: false,
startX: 0,
startWidth: 320,
});
const STACK_PANEL_MIN_WIDTH = 240;
const STACK_PANEL_MAX_WIDTH = 720;
const SIDE_MENU_WIDTH = 220;
const SIDE_MENU_BUTTON_SIZE = 40;
const SIDE_MENU_BUTTON_PEEK = 14;
const topControlsRef = useRef<HTMLDivElement | null>(null);
const [fullscreenHoverIdx, setFullscreenHoverIdx] = useState<number | null>(null);
const [openDropdownIdx, setOpenDropdownIdx] = useState<number | null>(null);
const [topControlsPeek, setTopControlsPeek] = useState(false);
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 [volumeDiagnosticsEnabled, setVolumeDiagnosticsEnabled] = useState(false);
const [availableStacks, setAvailableStacks] = useState<StackEntry[]>([]);
const [activeViewport, setActiveViewport] = useState<number | null>(0);
const [draggedStackIdx, setDraggedStackIdx] = useState<number | null>(null);
const [dropPreview, setDropPreview] = useState<{ viewportIdx: number; zone: ViewportDropZone } | null>(null);
// 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)
);
// Client-side generated thumbnails cache: stackIndex -> dataURL
const [stackThumbnails, setStackThumbnails] = useState<Record<number, string>>({});
const thumbRequestedRef = useRef<Record<number, boolean>>({});
// Helper to find a canvas element under a given node (recursive)
function findCanvasIn(node: ParentNode | null): HTMLCanvasElement | null {
if (!node) return null;
try {
if ((node as Element).tagName && (node as Element).tagName.toLowerCase() === 'canvas') {
return node as unknown as HTMLCanvasElement;
}
} catch (e) {
// ignore
}
try {
const q = (node as Element).querySelector && (node as Element).querySelector('canvas');
if (q) return q as HTMLCanvasElement;
} catch (e) {
// ignore
}
try {
const children = (node as Element).children || [];
for (let i = 0; i < children.length; i++) {
const found = findCanvasIn(children[i]);
if (found) return found;
}
} catch (e) {
// ignore
}
return null;
}
// Helper to detect external (non-DICOM) image ids
function isExternalImageId(imageId: string | null | undefined) {
if (!imageId) return false;
// Treat wadouri: as DICOM (our existing loader)
if (imageId.startsWith('wadouri:')) return false;
if (imageId.startsWith('external:')) return true;
// blob: or data:image/ or common extensions
if (imageId.startsWith('blob:') || imageId.startsWith('data:image/')) return true;
const lower = imageId.toLowerCase();
if (lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.jpeg') || lower.endsWith('.gif')) return true;
return false;
}
// Enable simple pan & zoom interactions for an <img> placed inside a viewport element.
// Attaches event listeners and returns a cleanup function.
function enableImagePanZoom(container: HTMLElement, img: HTMLImageElement, options?: { allowPan?: boolean, allowZoom?: boolean }) {
const allowPanInit = options?.allowPan ?? true;
const allowZoomInit = options?.allowZoom ?? true;
let scale = 1;
let translateX = 0;
let translateY = 0;
let isPanning = false;
let startX = 0;
let startY = 0;
let lastClientX = 0;
let lastClientY = 0;
img.style.transformOrigin = '0 0';
img.style.willChange = 'transform';
img.style.cursor = 'grab';
const clamp = (v: number, a = 0.1, b = 10) => Math.min(b, Math.max(a, v));
const apply = () => {
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
};
let allowPan = allowPanInit;
let allowZoom = allowZoomInit;
const onWheel = (e: WheelEvent) => {
if (!allowZoom) return;
// Zoom on wheel
e.preventDefault();
const rect = img.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
const delta = -e.deltaY;
const zoomFactor = 1 + (delta > 0 ? 0.1 : -0.1) * Math.min(4, Math.abs(delta) / 100);
const newScale = clamp(scale * zoomFactor, 0.1, 10);
// Keep point under cursor stationary
translateX = (translateX - offsetX) * (newScale / scale) + offsetX;
translateY = (translateY - offsetY) * (newScale / scale) + offsetY;
scale = newScale;
apply();
};
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return;
if (!allowPan) return;
isPanning = true;
startX = translateX;
startY = translateY;
lastClientX = e.clientX;
lastClientY = e.clientY;
img.style.cursor = 'grabbing';
e.preventDefault();
};
const onMouseMove = (e: MouseEvent) => {
if (!isPanning) return;
const dx = e.clientX - lastClientX;
const dy = e.clientY - lastClientY;
translateX += dx;
translateY += dy;
lastClientX = e.clientX;
lastClientY = e.clientY;
apply();
};
const onMouseUp = (e: MouseEvent) => {
if (!isPanning) return;
isPanning = false;
img.style.cursor = 'grab';
};
const onDblClick = (e: MouseEvent) => {
// reset
scale = 1;
translateX = 0;
translateY = 0;
apply();
};
// Touch support: simple panning (single touch) and pinch-to-zoom (two touches)
let lastTouchDist = 0;
const getTouchDist = (t1: Touch, t2: Touch) => Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length === 1) {
if (allowPan) {
isPanning = true;
lastClientX = e.touches[0].clientX;
lastClientY = e.touches[0].clientY;
}
} else if (e.touches.length === 2) {
lastTouchDist = getTouchDist(e.touches[0], e.touches[1]);
}
};
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length === 1 && isPanning) {
const dx = e.touches[0].clientX - lastClientX;
const dy = e.touches[0].clientY - lastClientY;
translateX += dx;
translateY += dy;
lastClientX = e.touches[0].clientX;
lastClientY = e.touches[0].clientY;
apply();
} else if (e.touches.length === 2 && allowZoom) {
const curDist = getTouchDist(e.touches[0], e.touches[1]);
if (lastTouchDist > 0) {
const zoomFactor = curDist / lastTouchDist;
const rect = img.getBoundingClientRect();
const centerX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
const centerY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
const newScale = clamp(scale * zoomFactor, 0.1, 10);
translateX = (translateX - centerX) * (newScale / scale) + centerX;
translateY = (translateY - centerY) * (newScale / scale) + centerY;
scale = newScale;
apply();
}
lastTouchDist = curDist;
}
e.preventDefault();
};
const onTouchEnd = (e: TouchEvent) => {
if (e.touches.length === 0) {
isPanning = false;
lastTouchDist = 0;
}
};
// Attach listeners to the container so we get events even if the img is not full-size
container.addEventListener('wheel', onWheel, { passive: false });
container.addEventListener('mousedown', onMouseDown as any);
window.addEventListener('mousemove', onMouseMove as any);
window.addEventListener('mouseup', onMouseUp as any);
img.addEventListener('dblclick', onDblClick as any);
container.addEventListener('touchstart', onTouchStart as any, { passive: false });
container.addEventListener('touchmove', onTouchMove as any, { passive: false });
container.addEventListener('touchend', onTouchEnd as any);
const cleanup = () => {
container.removeEventListener('wheel', onWheel as any);
container.removeEventListener('mousedown', onMouseDown as any);
window.removeEventListener('mousemove', onMouseMove as any);
window.removeEventListener('mouseup', onMouseUp as any);
img.removeEventListener('dblclick', onDblClick as any);
container.removeEventListener('touchstart', onTouchStart as any);
container.removeEventListener('touchmove', onTouchMove as any);
container.removeEventListener('touchend', onTouchEnd as any);
// reset transform
img.style.transform = '';
img.style.cursor = '';
img.style.willChange = '';
};
// Return control functions so we can update allowed interactions later
return {
cleanup,
setAllowPan: (v: boolean) => { allowPan = v; },
setAllowZoom: (v: boolean) => { allowZoom = v; },
};
}
const [stackOrderModified, setStackOrderModified] = useState(false);
const [loadingState, setLoadingState] = useState<null | "annotations" | "viewerState" | "displayset">(null);
// Show a warning when a WebGL/graphics resource error (e.g. releaseGraphicsResources) occurs
const [graphicsErrorVisible, setGraphicsErrorVisible] = useState(false);
const [graphicsErrorMessage, setGraphicsErrorMessage] = useState<string | null>(null);
useEffect(() => {
const onError = (ev: ErrorEvent) => {
try {
const msg = ev && ev.message ? String(ev.message) : '';
// Detect known rendering/WebGL-related errors and actor/colormap issues
if (
msg.includes('releaseGraphicsResources') ||
msg.includes('openGLTexture') ||
msg.includes('getDefaultActor') ||
msg.includes('setColormapGPU') ||
msg.includes("can't access property \"actor\"") ||
// generic rendering failure
msg.toLowerCase().includes('webgl') ||
msg.toLowerCase().includes('colormap')
) {
console.error('Detected graphics/rendering error:', ev);
setGraphicsErrorMessage(msg || null);
setGraphicsErrorVisible(true);
}
} catch (e) {
// ignore
}
};
const onRejection = (ev: PromiseRejectionEvent) => {
try {
const reason = (ev && (ev as any).reason) || '';
const text = typeof reason === 'string' ? reason : (reason && reason.message) ? reason.message : String(reason);
if (
text.includes('releaseGraphicsResources') ||
text.includes('openGLTexture') ||
text.includes('getDefaultActor') ||
text.includes('setColormapGPU') ||
text.includes("can't access property \"actor\"") ||
text.toLowerCase().includes('webgl') ||
text.toLowerCase().includes('colormap')
) {
console.error('Detected graphics/rendering rejection:', ev);
setGraphicsErrorMessage(text || null);
setGraphicsErrorVisible(true);
}
} catch (e) {
// ignore
}
};
window.addEventListener('error', onError as EventListener);
window.addEventListener('unhandledrejection', onRejection as EventListener);
return () => {
window.removeEventListener('error', onError as EventListener);
window.removeEventListener('unhandledrejection', onRejection as EventListener);
};
}, []);
// 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 = stripWadouriPrefix(first);
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;
setLoadingState("displayset");
try {
// 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);
} finally {
setLoadingState(null);
}
};
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;
});
};
const generateThumbnail = useCallback(async (stackIdx: number, chosenImageId: string | null) => {
if (!chosenImageId) {
console.warn('generateThumbnail: no chosenImageId for stack', stackIdx);
return;
}
// avoid regenerating
if (stackThumbnails[stackIdx]) {
console.debug('generateThumbnail: thumbnail already exists for', stackIdx);
// already have it
return;
}
if (thumbRequestedRef.current[stackIdx]) {
console.debug('generateThumbnail: already requested for', stackIdx);
return;
}
// If the chosen image is an external non-DICOM image, draw it to a canvas directly
if (isExternalImageId(chosenImageId)) {
try {
const img = new Image();
img.crossOrigin = 'anonymous';
const src = (chosenImageId as string).replace(/^external:/, '');
img.src = src;
await new Promise((resolve, reject) => {
img.onload = () => resolve(true);
img.onerror = (e) => reject(e);
});
const canvas = document.createElement('canvas');
const size = 160;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (ctx) {
// draw with aspect-fit
const ratio = Math.min(size / img.width, size / img.height);
const w = img.width * ratio;
const h = img.height * ratio;
const x = (size - w) / 2;
const y = (size - h) / 2;
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, size, size);
ctx.drawImage(img, x, y, w, h);
const dataUrl = canvas.toDataURL('image/png');
setStackThumbnails(prev => ({ ...prev, [stackIdx]: dataUrl }));
} else {
setStackThumbnails(prev => ({ ...prev, [stackIdx]: 'BROKEN' }));
}
} catch (err) {
console.warn('generateThumbnail: failed to generate thumbnail for external image', err);
setStackThumbnails(prev => ({ ...prev, [stackIdx]: 'BROKEN' }));
}
return;
}
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) {
console.warn('generateThumbnail: no renderingEngine yet for', stackIdx);
return;
}
// mark requested once we are sure the rendering engine exists
thumbRequestedRef.current[stackIdx] = true;
console.debug('generateThumbnail: starting for', stackIdx, chosenImageId);
const viewportId = `THUMB_${container_id || 'main'}_${stackIdx}`;
const el = document.createElement('div');
el.style.width = '160px';
el.style.height = '160px';
el.style.position = 'absolute';
el.style.left = '-9999px';
el.style.top = '0';
document.body.appendChild(el);
try {
renderingEngine.enableElement({ viewportId, type: Enums.ViewportType.STACK, element: el });
const vp = renderingEngine.getViewport(viewportId);
if (!vp) {
console.warn('generateThumbnail: no viewport returned after enableElement for', viewportId);
return;
}
// Use single-image stack for thumbnail
try {
vp.setStack([chosenImageId]);
} catch (err) {
console.warn('generateThumbnail: vp.setStack failed', err);
}
// Wait for IMAGE_RENDERED event on the temporary viewport element or timeout
const canvasReady = await new Promise<boolean>(resolve => {
let resolved = false;
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
console.warn('generateThumbnail: IMAGE_RENDERED timeout for', viewportId);
resolve(false);
}
}, 2000);
const onRendered = () => {
if (resolved) return;
resolved = true;
clearTimeout(timeout);
console.debug('generateThumbnail: IMAGE_RENDERED received for', viewportId);
resolve(true);
};
try {
if (vp && vp.element) {
try {
vp.element.addEventListener(IMAGE_RENDERED, onRendered, { once: true } as any);
} catch (err) {
console.warn('generateThumbnail: failed to add IMAGE_RENDERED listener', err);
}
}
// Trigger a render in case it doesn't auto-render.
// vp.render may return undefined (not a Promise), so guard before calling .catch()
if (typeof vp.render === 'function') {
try {
const res = vp.render();
if (res && typeof (res as any).then === 'function') {
(res as any).catch(() => undefined);
}
} catch (err) {
// ignore render errors here
}
}
} catch (err) {
clearTimeout(timeout);
console.warn('generateThumbnail: error while waiting for render', err);
resolve(false);
}
});
// Find canvas inside the temporary element or viewport element. Try multiple locations.
let canvas = findCanvasIn(el) || (vp && vp.element ? findCanvasIn(vp.element) : null);
if (!canvas) {
console.debug('generateThumbnail: no canvas found immediately, waiting briefly and retrying for', viewportId, 'canvasReady=', canvasReady);
await new Promise(r => setTimeout(r, 150));
canvas = findCanvasIn(el) || (vp && vp.element ? findCanvasIn(vp.element) : null);
}
if (canvas) {
try {
const dataUrl = canvas.toDataURL('image/png');
setStackThumbnails(prev => ({ ...prev, [stackIdx]: dataUrl }));
console.debug('generateThumbnail: thumbnail captured for stack', stackIdx);
} catch (err) {
console.warn('generateThumbnail: toDataURL failed', err);
// Mark as broken so UI shows placeholder instead of attempting to load raw DICOM URL
setStackThumbnails(prev => ({ ...prev, [stackIdx]: 'BROKEN' }));
}
} else {
console.warn('generateThumbnail: no canvas found after render for', viewportId, 'canvasReady=', canvasReady, 'vp.element=', !!(vp && vp.element));
// Mark as broken so we don't attempt to use raw .dcm URL as <img> src
setStackThumbnails(prev => ({ ...prev, [stackIdx]: 'BROKEN' }));
}
} catch (err) {
// log and mark broken so UI shows placeholder
console.warn('Thumbnail generation failed for', chosenImageId, err);
try { setStackThumbnails(prev => ({ ...prev, [stackIdx]: 'BROKEN' })); } catch (e) { /* ignore */ }
} finally {
try { renderingEngine.disableElement(viewportId); } catch (e) {
console.warn('Failed to disable thumbnail element:', e);
}
if (el && el.parentNode) el.parentNode.removeChild(el);
}
}, [container_id, stackThumbnails]);
// Kick off thumbnail generation when stacks change
useEffect(() => {
if (!availableStacks || availableStacks.length === 0) return;
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) return; // wait until rendering engine exists
availableStacks.forEach((stack, idx) => {
const imageIds = Array.isArray(stack.imageIds) ? stack.imageIds : [];
const midIndex = Math.floor(imageIds.length / 2);
const chosen = imageIds[midIndex] || imageIds[0] || null;
if (!stackThumbnails[idx] && !thumbRequestedRef.current[idx]) {
generateThumbnail(idx, chosen as string | null);
}
});
}, [availableStacks, generateThumbnail, stackThumbnails]);
// Wrap setLoadedImageIds to also cache DICOM metadata
const setLoadedImageIdsAndCacheMeta = async (imageIds: string[], options?: { force?: boolean }) => {
console.log("Setting loaded imageIds:", imageIds);
console.log("autoCacheStack:", autoCacheStack);
const loadPromises: Promise<any>[] = [];
const shouldCache = Boolean(autoCacheStack || options?.force);
if (shouldCache) {
for (const imageId of imageIds) {
if (imageId.startsWith("wadouri:")) {
try {
const url = stripWadouriPrefix(imageId);
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);
const getDropZoneFromPointer = useCallback((e: React.DragEvent<HTMLDivElement>): ViewportDropZone => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const w = rect.width;
const h = rect.height;
const edgeThreshold = Math.min(w, h) * 0.24;
const distances = [
{ zone: "left" as const, distance: x },
{ zone: "right" as const, distance: w - x },
{ zone: "top" as const, distance: y },
{ zone: "bottom" as const, distance: h - y },
];
distances.sort((a, b) => a.distance - b.distance);
return distances[0].distance <= edgeThreshold ? distances[0].zone : "center";
}, []);
const resolveDropZone = useCallback((zone: ViewportDropZone): ViewportDropZone => {
if (zone === "left" || zone === "right") {
return viewportGrid.cols < 3 ? zone : "center";
}
if (zone === "top" || zone === "bottom") {
return viewportGrid.rows < 3 ? zone : "center";
}
return "center";
}, [viewportGrid]);
// 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,
modality: string,
orientation: string,
resolution: string,
spacing: string,
focalPoint: string,
}>>(() =>
Array(viewportGrid.rows * viewportGrid.cols).fill({
index: 0,
total: 0,
windowCenter: "",
windowWidth: "",
slicePosition: "",
modality: "",
orientation: "",
resolution: "",
spacing: "",
focalPoint: "",
})
);
const [viewedIndicesByViewport, setViewedIndicesByViewport] = useState<number[][]>(
() => Array(MAX_VIEWPORTS).fill(null).map(() => [])
);
const viewportStackSignaturesRef = useRef<string[]>(Array(MAX_VIEWPORTS).fill(""));
const defaultVoiRangeByViewportRef = useRef<Array<{ lower: number; upper: number } | null>>(Array(MAX_VIEWPORTS).fill(null));
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);
const [shiftPressed, setShiftPressed] = 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);
if (e.shiftKey) setShiftPressed(true);
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!e.altKey) setAltPressed(false);
if (!e.shiftKey) setShiftPressed(false);
};
const handleBlur = () => {
setAltPressed(false);
setShiftPressed(false);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
};
}, []);
useEffect(() => {
const mainToolGroup = ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID);
if (!mainToolGroup) return;
const modifierActive = shiftPressed || (altEnablesReferenceCursors && altPressed);
if (modifierActive) {
// Force metadata warm-up for visible stack viewports so nearest-slice lookup
// can consider the entire stack (including images not yet viewed/cached).
const visibleCount = viewportGrid.rows * viewportGrid.cols;
const visibleStackImageIds = viewportImageIds
.slice(0, visibleCount)
.filter((ids, idx) => viewportModes[idx] === 'stack' && Array.isArray(ids) && ids.length > 0);
visibleStackImageIds.forEach((ids) => {
setLoadedImageIdsAndCacheMeta(ids, { force: true }).catch((err) => {
console.warn('Reference cursor metadata warm-up failed:', err);
});
});
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,
shiftPressed,
altEnablesReferenceCursors,
viewportGrid.rows,
viewportGrid.cols,
viewportImageIds,
viewportModes,
setLoadedImageIdsAndCacheMeta,
]);
// 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 });
});
// Also update external image handlers (pan/zoom) to respect selected tools
try {
const primaryTool = mouseToolBindings.Primary;
const wheelTool = mouseToolBindings.Wheel;
const auxTool = mouseToolBindings.Auxiliary;
// Determine whether pan/zoom should be enabled for primary/wheel interactions
const allowPan = primaryTool === PanTool.toolName || auxTool === PanTool.toolName;
const allowZoom = primaryTool === ZoomTool.toolName || wheelTool === ZoomTool.toolName || mouseToolBindings.Primary_and_Secondary === ZoomTool.toolName;
// Iterate viewports and update external handlers
viewportRefs.current.forEach((el) => {
if (!el) return;
const ext = (el as any)._externalPanZoom;
if (ext) {
if (typeof ext.setAllowPan === 'function') ext.setAllowPan(!!allowPan);
if (typeof ext.setAllowZoom === 'function') ext.setAllowZoom(!!allowZoom);
}
});
} catch (e) {
// ignore
}
//// 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);
const [folderLoadMode, setFolderLoadMode] = useState<'add' | 'replace'>('add');
const folderInputRef = useRef<HTMLInputElement | null>(null);
const beginStackPanelResize = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
if (!stackPanelOpen) return;
event.preventDefault();
event.stopPropagation();
stackPanelResizeRef.current = {
resizing: true,
startX: event.clientX,
startWidth: stackPanelWidth,
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, [stackPanelOpen, stackPanelWidth]);
useEffect(() => {
const onMouseMove = (event: MouseEvent) => {
if (!stackPanelResizeRef.current.resizing) return;
const delta = stackPanelResizeRef.current.startX - event.clientX;
const nextWidth = Math.max(
STACK_PANEL_MIN_WIDTH,
Math.min(STACK_PANEL_MAX_WIDTH, stackPanelResizeRef.current.startWidth + delta)
);
setStackPanelWidth(nextWidth);
};
const onMouseUp = () => {
if (!stackPanelResizeRef.current.resizing) return;
stackPanelResizeRef.current.resizing = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, []);
const sideMenuButtonLeft = sideMenuOpen
? SIDE_MENU_WIDTH - (SIDE_MENU_BUTTON_SIZE / 2)
: -SIDE_MENU_BUTTON_SIZE + SIDE_MENU_BUTTON_PEEK;
useEffect(() => {
// Some browsers don't accept non-standard props via JSX typings; set attributes directly.
try {
if (folderInputRef.current) {
folderInputRef.current.setAttribute('webkitdirectory', 'true');
folderInputRef.current.setAttribute('directory', '');
folderInputRef.current.setAttribute('mozdirectory', '');
}
} catch (e) {
// ignore
}
}, []);
// Recursively collect files from a DirectoryHandle (File System Access API)
async function collectFilesFromDirectoryHandle(dirHandle: any, pathPrefix = ''): Promise<File[]> {
const files: File[] = [];
// Prefer entries() which yields [name, handle] pairs — more explicit
const entriesIter = (dirHandle as any).entries ? (dirHandle as any).entries() : (dirHandle as any).values();
for await (const entryPair of entriesIter) {
try {
// entryPair may be [name, handle] or a handle directly
let entry: any;
let entryName: string | undefined;
if (Array.isArray(entryPair) && entryPair.length >= 2) {
entryName = entryPair[0];
entry = entryPair[1];
} else {
entry = entryPair;
entryName = entry.name;
}
if (!entry) continue;
if (entry.kind === 'file') {
const f = await entry.getFile();
try {
Object.defineProperty(f, 'webkitRelativePath', { value: pathPrefix + (entryName || entry.name), configurable: true });
} catch (e) {
// ignore if not possible
}
files.push(f);
} else if (entry.kind === 'directory') {
// Recurse into subdirectory
const nested = await collectFilesFromDirectoryHandle(entry, pathPrefix + (entryName || entry.name) + '/');
files.push(...nested);
}
} catch (e) {
// ignore individual entry errors
console.debug('collectFilesFromDirectoryHandle: skipping entry due to error', e);
}
}
return files;
}
// Try to open the native directory picker when available, otherwise fall back to the hidden input
const handleFolderPick = async () => {
if ((window as any).showDirectoryPicker) {
try {
const dirHandle = await (window as any).showDirectoryPicker();
const files = await collectFilesFromDirectoryHandle(dirHandle);
if (files && files.length > 0) {
// Call existing file handler with our array
await handleFilesSelected({ target: { files } } as any);
} else {
alert('No files found in selected folder.');
}
return;
} catch (e) {
console.warn('showDirectoryPicker failed or cancelled', e);
// fallthrough to input click
}
}
// Fallback: click the folder input (webkitdirectory attribute should be set at runtime)
folderInputRef.current?.click();
};
// 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));
// Parse each file to extract DICOM tags for grouping (SeriesInstanceUID / StudyInstanceUID)
const parsedEntries = await Promise.all(fileArray.map(async (file) => {
const isImage = file.type && file.type.startsWith('image/');
let studyInstanceUID: string | undefined = undefined;
let seriesInstanceUID: string | undefined = undefined;
let instanceNumber: number | undefined = undefined;
let sliceLocation: number | undefined = undefined;
let parsedSuccessfully = false;
if (!isImage) {
try {
const arrayBuffer = await file.arrayBuffer();
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
studyInstanceUID = dataSet.string('x0020000d');
seriesInstanceUID = dataSet.string('x0020000e');
const instStr = dataSet.string('x00200013');
if (instStr !== undefined && instStr !== null && instStr !== '') instanceNumber = parseInt(instStr as string, 10);
const sliceStr = dataSet.string('x00201041') || dataSet.string('x00201040');
if (sliceStr !== undefined && sliceStr !== null && sliceStr !== '') sliceLocation = parseFloat(sliceStr as string);
parsedSuccessfully = true;
} catch (e) {
// Not a DICOM or failed to parse
parsedSuccessfully = false;
}
}
const imageId = isImage ? `external:${URL.createObjectURL(file)}` : `wadouri:${URL.createObjectURL(file)}`;
return { file, imageId, studyInstanceUID, seriesInstanceUID, instanceNumber, sliceLocation, isImage, parsedSuccessfully };
}));
// Group parsed entries by SeriesInstanceUID (fallback to StudyInstanceUID or filename folder)
const groups: Map<string, any> = new Map();
for (const entry of parsedEntries) {
// Skip files that are neither images nor successfully parsed DICOMs
if (!entry.isImage && !entry.parsedSuccessfully) {
console.debug('Skipping non-DICOM/non-image file:', entry.file.name);
continue;
}
const key = entry.seriesInstanceUID || entry.studyInstanceUID || (entry.file.webkitRelativePath ? entry.file.webkitRelativePath.split('/')[0] : entry.file.name);
if (!groups.has(key)) {
groups.set(key, { entries: [], studyInstanceUID: entry.studyInstanceUID, seriesInstanceUID: entry.seriesInstanceUID });
}
groups.get(key).entries.push(entry);
}
// Build stacks from groups, sorting within each group by requested order
const stacks: Array<any> = [];
for (const [key, g] of groups.entries()) {
const entries = g.entries as Array<any>;
// Sort: prefer sliceLocation (if ordering requested), then instanceNumber, then filename
entries.sort((a, b) => {
if (orderBySliceLocation && a.sliceLocation !== undefined && b.sliceLocation !== undefined) return (a.sliceLocation - b.sliceLocation);
if (a.instanceNumber !== undefined && b.instanceNumber !== undefined) return (a.instanceNumber - b.instanceNumber);
return (a.file.webkitRelativePath || a.file.name).localeCompare(b.file.webkitRelativePath || b.file.name);
});
const imageIds = entries.map(e => e.imageId);
stacks.push({ imageIds, studyInstanceUID: g.studyInstanceUID, seriesInstanceUID: g.seriesInstanceUID });
}
if (stacks.length === 0) return; // nothing to do
if (folderLoadMode === 'replace') {
// Replace everything currently loaded with this new stack
try {
const renderingEngine = renderingEngineRef.current;
if (renderingEngine) {
for (let i = 0; i < MAX_VIEWPORTS; i++) {
const viewportId = `CT_${i}`;
try {
if (renderingEngine.getViewport(viewportId)) {
renderingEngine.disableElement(viewportId);
}
} catch (e) { /* ignore */ }
try {
const el = viewportRefs.current[i];
if (el) {
try { if ((el as any)._externalPanZoom && (el as any)._externalPanZoom.cleanup) (el as any)._externalPanZoom.cleanup(); } catch (e) {}
try { if ((el as any)._resizeObserver) { (el as any)._resizeObserver.disconnect(); (el as any)._resizeObserver = undefined; } } catch (e) {}
el.innerHTML = '';
(el as any)._cornerstoneEnabled = false;
(el as any)._lastViewportType = undefined;
}
} catch (e) { /* ignore */ }
}
}
} catch (e) { /* ignore */ }
// Replace with the grouped stacks
setAvailableStacks(stacks);
// Populate viewports with the first N stacks (repeat first if fewer stacks than viewports)
setViewportImageIds(() => {
const arr = Array(MAX_VIEWPORTS).fill([] as string[]);
for (let i = 0; i < MAX_VIEWPORTS; i++) {
arr[i] = stacks[i] ? stacks[i].imageIds : stacks[0].imageIds;
}
return arr;
});
setSelectedStackIdx(Array(MAX_VIEWPORTS).fill(0));
// Cache metadata for all stacks (fire-and-forget)
for (const s of stacks) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
setLoadedImageIdsAndCacheMeta(s.imageIds);
}
} else {
// Append (add) all grouped stacks
setAvailableStacks(prev => {
const next = [...prev, ...stacks];
// set first viewport to the last appended stack
setViewportImageIds(vpPrev => {
const vpNext = [...vpPrev];
vpNext[0] = stacks[stacks.length - 1].imageIds;
return vpNext;
});
setSelectedStackIdx(selPrev => {
const selNext = [...selPrev];
selNext[0] = next.length - 1;
return selNext;
});
// Cache metadata for appended stacks
for (const s of stacks) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
setLoadedImageIdsAndCacheMeta(s.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 = "";
let modality = "";
let orientation = "";
let resolution = "";
let spacing = "";
let focalPoint = "";
if (viewportModes[viewportIdx] === "stack") {
currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
? viewport.getCurrentImageIdIndex()
: 0;
const imageIds = typeof viewport.getImageIds === "function"
? viewport.getImageIds()
: [];
total = imageIds.length;
const currentImageId = imageIds[currentIndex];
if (currentImageId) {
const seriesMeta = metaData.get("generalSeriesModule", currentImageId) as any;
const planeMeta = metaData.get("imagePlaneModule", currentImageId) as any;
const pixelMeta = metaData.get("imagePixelModule", currentImageId) as any;
modality = seriesMeta?.modality || "";
if (pixelMeta?.rows && pixelMeta?.columns) {
resolution = `${pixelMeta.columns} x ${pixelMeta.rows}`;
}
const spacingParts = [
planeMeta?.columnPixelSpacing,
planeMeta?.rowPixelSpacing,
planeMeta?.sliceThickness,
].filter((value) => value !== undefined && value !== null && value !== "");
if (spacingParts.length > 0) {
spacing = spacingParts.join(" x ");
}
}
if (typeof viewport.getCamera === "function") {
const camera = viewport.getCamera();
if (camera?.focalPoint && Array.isArray(camera.focalPoint)) {
focalPoint = camera.focalPoint.map((value: number) => value.toFixed(1)).join(", ");
}
}
if (currentIndex >= 0) {
setViewedIndicesByViewport((prev) => {
const next = [...prev];
const existing = new Set(next[viewportIdx] || []);
if (!existing.has(currentIndex)) {
existing.add(currentIndex);
next[viewportIdx] = Array.from(existing).sort((a, b) => a - b);
return next;
}
return prev;
});
}
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);
if (defaultVoiRangeByViewportRef.current[viewportIdx] === null) {
defaultVoiRangeByViewportRef.current[viewportIdx] = { lower, upper };
}
}
}
} 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);
if (defaultVoiRangeByViewportRef.current[viewportIdx] === null) {
defaultVoiRangeByViewportRef.current[viewportIdx] = { lower, upper };
}
}
}
// 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";
}
}
const firstImageId = viewportImageIds[viewportIdx]?.[0];
if (firstImageId) {
const seriesMeta = metaData.get("generalSeriesModule", firstImageId) as any;
const planeMeta = metaData.get("imagePlaneModule", firstImageId) as any;
const pixelMeta = metaData.get("imagePixelModule", firstImageId) as any;
modality = seriesMeta?.modality || "";
orientation = String(viewport.viewportProperties?.orientation || "").toUpperCase();
if (pixelMeta?.rows && pixelMeta?.columns) {
resolution = `${pixelMeta.columns} x ${pixelMeta.rows}`;
}
const spacingParts = [
planeMeta?.columnPixelSpacing,
planeMeta?.rowPixelSpacing,
planeMeta?.sliceThickness,
].filter((value) => value !== undefined && value !== null && value !== "");
if (spacingParts.length > 0) {
spacing = spacingParts.join(" x ");
}
}
if (typeof viewport.getCamera === "function") {
const camera = viewport.getCamera();
if (camera?.focalPoint && Array.isArray(camera.focalPoint)) {
focalPoint = camera.focalPoint.map((value: number) => value.toFixed(1)).join(", ");
}
}
}
setImageInfos(prev => {
const next = [...prev];
next[viewportIdx] = {
index: currentIndex + 1,
total,
windowCenter,
windowWidth,
slicePosition, // add this field
modality,
orientation,
resolution,
spacing,
focalPoint,
};
return next;
});
}, [viewportModes, viewportImageIds]);
useEffect(() => {
setViewedIndicesByViewport((prev) => {
let changed = false;
const next = [...prev];
for (let i = 0; i < MAX_VIEWPORTS; i++) {
const ids = viewportImageIds[i] || [];
const signature = `${ids.length}:${ids[0] || ""}:${ids[ids.length - 1] || ""}`;
if (viewportStackSignaturesRef.current[i] !== signature) {
viewportStackSignaturesRef.current[i] = signature;
defaultVoiRangeByViewportRef.current[i] = null;
next[i] = [];
changed = true;
}
}
return changed ? next : prev;
});
}, [viewportImageIds]);
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();
}
}
}
}
const applyStackDrop = async (viewportIdx: number, stackIdx: number, requestedZone: ViewportDropZone) => {
const stackObj = availableStacks[stackIdx];
if (!stackObj) return;
const resolvedZone = resolveDropZone(requestedZone);
if (resolvedZone === "center") {
await handleLoadStack(viewportIdx, stackIdx);
setActiveViewport(viewportIdx);
return;
}
const oldRows = viewportGrid.rows;
const oldCols = viewportGrid.cols;
const row = Math.floor(viewportIdx / oldCols);
const col = viewportIdx % oldCols;
const newRows = (resolvedZone === "top" || resolvedZone === "bottom") ? oldRows + 1 : oldRows;
const newCols = (resolvedZone === "left" || resolvedZone === "right") ? oldCols + 1 : oldCols;
if (newRows > 3 || newCols > 3 || newRows * newCols > MAX_VIEWPORTS) {
await handleLoadStack(viewportIdx, stackIdx);
setActiveViewport(viewportIdx);
return;
}
const nextImageIds = Array(MAX_VIEWPORTS).fill([] as string[]);
const nextModes = Array(MAX_VIEWPORTS).fill("stack") as Array<'stack' | 'volume'>;
const nextSelected = Array(MAX_VIEWPORTS).fill(0);
const targetNewRow = resolvedZone === "top" ? row : resolvedZone === "bottom" ? row + 1 : row;
const targetNewCol = resolvedZone === "left" ? col : resolvedZone === "right" ? col + 1 : col;
const insertedViewportIndex = targetNewRow * newCols + targetNewCol;
for (let oldIdx = 0; oldIdx < oldRows * oldCols; oldIdx++) {
const oldRow = Math.floor(oldIdx / oldCols);
const oldCol = oldIdx % oldCols;
let mappedRow = oldRow;
let mappedCol = oldCol;
if (resolvedZone === "left" || resolvedZone === "right") {
if (oldRow === row) {
if (oldCol < col) {
mappedCol = oldCol;
} else if (oldCol > col) {
mappedCol = oldCol + 1;
} else {
mappedCol = resolvedZone === "left" ? oldCol + 1 : oldCol;
}
}
}
if (resolvedZone === "top" || resolvedZone === "bottom") {
if (oldCol === col) {
if (oldRow < row) {
mappedRow = oldRow;
} else if (oldRow > row) {
mappedRow = oldRow + 1;
} else {
mappedRow = resolvedZone === "top" ? oldRow + 1 : oldRow;
}
}
}
const mappedIdx = mappedRow * newCols + mappedCol;
nextImageIds[mappedIdx] = viewportImageIds[oldIdx] || [];
nextModes[mappedIdx] = viewportModes[oldIdx] || "stack";
nextSelected[mappedIdx] = selectedStackIdx[oldIdx] ?? 0;
}
nextImageIds[insertedViewportIndex] = stackObj.imageIds;
nextModes[insertedViewportIndex] = "stack";
nextSelected[insertedViewportIndex] = stackIdx;
setLoadingState("displayset");
try {
saveVisibleViewportState();
setViewportGrid({ rows: newRows, cols: newCols });
setViewportImageIds(nextImageIds);
setViewportModes(nextModes);
setSelectedStackIdx(nextSelected);
setActiveViewport(insertedViewportIndex);
await setLoadedImageIdsAndCacheMeta(stackObj.imageIds);
} finally {
setLoadingState(null);
}
};
useEffect(() => {
const setup = async () => {
console.log("setup")
// 1. Initialize Cornerstone3D and tools ONCE
if (!running.current) {
running.current = true;
await csRenderInit();
await csToolsInit(
);
dicomImageLoaderInit();
// Register client-side image loader for external images (JPEG/PNG/blob/data)
try {
imageLoader.registerImageLoader('external', (imageId: string) => {
const promise = (async () => {
// imageId expected as 'external:<url>'
const url = imageId.replace(/^external:/, '');
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = url;
await new Promise((resolve, reject) => {
img.onload = () => resolve(true);
img.onerror = (e) => reject(e);
});
const w = img.naturalWidth || img.width;
const h = img.naturalHeight || img.height;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Unable to create canvas context');
ctx.drawImage(img, 0, 0, w, h);
const im = ctx.getImageData(0, 0, w, h);
const image = {
imageId,
minPixelValue: 0,
maxPixelValue: 255,
slope: 1.0,
intercept: 0,
rows: h,
columns: w,
height: h,
width: w,
color: true,
rgba: true,
sizeInBytes: im.data.length,
getPixelData: () => im.data,
// Some Cornerstone APIs expect `getImageData` or `getCanvas` - provide canvas and ImageData
getImageData: () => im,
getCanvas: () => canvas,
} as any;
return image;
})();
return { promise };
});
} catch (e) {
console.warn('Failed to register external image loader', e);
}
}
// 3. Get or create the rendering engine
let renderingEngine = renderingEngineRef.current;
if (!renderingEngine) {
renderingEngine = new RenderingEngine(renderingEngineId);
renderingEngineRef.current = renderingEngine;
// If stacks were already available before the engine was ready, kick off thumbnails now
try {
if (availableStacks && availableStacks.length > 0) {
availableStacks.forEach((stack, idx) => {
const imageIds = Array.isArray(stack.imageIds) ? stack.imageIds : [];
const midIndex = Math.floor(imageIds.length / 2);
const chosen = imageIds[midIndex] || imageIds[0] || null;
if (chosen && !stackThumbnails[idx] && !thumbRequestedRef.current[idx]) {
// fire-and-forget
// eslint-disable-next-line @typescript-eslint/no-floating-promises
generateThumbnail(idx, chosen as string | null);
}
});
}
} catch (e) {
// ignore
}
}
// --- 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) {
// If this is an external (non-DICOM) image stack, render an <img> element as a fallback
const firstId = imageIds[0];
if (isExternalImageId(firstId)) {
try {
// Cleanup previous external handlers if any
try {
const prev = (element as any)._externalPanZoom;
if (prev && typeof prev.cleanup === 'function') prev.cleanup();
} catch (e) { /* ignore */ }
// Clear existing content and insert an image element
element.innerHTML = '';
const img = document.createElement('img');
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'contain';
img.style.display = 'block';
img.src = (firstId as string).replace(/^external:/, '');
// allow drag to open in new tab etc.
img.setAttribute('draggable', 'false');
element.appendChild(img);
// Attach pan/zoom interactions to this external image
const ext = enableImagePanZoom(element, img);
(element as any)._externalPanZoom = ext;
// Do not attach DICOM-specific tools to external images
mainToolGroup.removeViewports(renderingEngineId, viewportId);
} catch (err) {
console.warn('Failed to render external image in viewport', err);
}
} else {
try {
viewport.setStack(imageIds);
} catch (err) {
console.warn('viewport.setStack failed', err);
}
}
}
// 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]);
const applyDefaultWindowLevel = useCallback((viewportIdx: number, resetCamera = false) => {
const viewportId = `CT_${viewportIdx}`;
try {
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(viewportId);
if (!viewport) return false;
const ids = typeof viewport.getImageIds === "function"
? viewport.getImageIds()
: (viewportImageIds[viewportIdx] || []);
const currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
? viewport.getCurrentImageIdIndex()
: 0;
const imageId = ids[currentIndex] || ids[0];
const defaultVoi = defaultVoiRangeByViewportRef.current[viewportIdx];
const voiMeta = imageId ? (metaData.get("voiLutModule", imageId) || {}) as any : {};
const centerMeta = Array.isArray(voiMeta.windowCenter) ? voiMeta.windowCenter[0] : voiMeta.windowCenter;
const widthMeta = Array.isArray(voiMeta.windowWidth) ? voiMeta.windowWidth[0] : voiMeta.windowWidth;
if (defaultVoi) {
viewport.setProperties({ voiRange: defaultVoi });
} else if (Number.isFinite(Number(centerMeta)) && Number.isFinite(Number(widthMeta)) && Number(widthMeta) > 0) {
const center = Number(centerMeta);
const width = Number(widthMeta);
viewport.setProperties({
voiRange: {
lower: center - width / 2,
upper: center + width / 2,
},
});
} else if (typeof viewport.resetProperties === "function") {
viewport.resetProperties();
}
if (resetCamera && typeof viewport.resetCamera === "function") {
viewport.resetCamera();
}
viewport.render();
updateOverlay(viewportIdx, viewport);
return true;
} catch (err) {
console.warn("Failed to apply default window level", err);
return false;
}
}, [updateOverlay, viewportImageIds]);
const handleAutoLevel = useCallback((viewportIdx: number) => {
const viewportId = `CT_${viewportIdx}`;
void (async () => {
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) return;
const viewport = renderingEngine.getViewport(viewportId);
if (!viewport) return;
const ids = typeof viewport.getImageIds === "function"
? viewport.getImageIds()
: (viewportImageIds[viewportIdx] || []);
const currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
? viewport.getCurrentImageIdIndex()
: 0;
const imageId = ids[currentIndex] || ids[0];
if (!imageId) {
applyDefaultWindowLevel(viewportIdx);
return;
}
const pixelMeta = (metaData.get("imagePixelModule", imageId) || {}) as any;
const lutMeta = (metaData.get("modalityLutModule", imageId) || {}) as any;
const voiMeta = (metaData.get("voiLutModule", imageId) || {}) as any;
const defaultVoi = defaultVoiRangeByViewportRef.current[viewportIdx];
let minRaw = Number(pixelMeta.smallestPixelValue ?? pixelMeta.smallestImagePixelValue);
let maxRaw = Number(pixelMeta.largestPixelValue ?? pixelMeta.largestImagePixelValue);
let slope = Number(lutMeta.rescaleSlope ?? 1);
let intercept = Number(lutMeta.rescaleIntercept ?? 0);
if (!Number.isFinite(minRaw) || !Number.isFinite(maxRaw) || maxRaw <= minRaw) {
try {
const image = await imageLoader.loadAndCacheImage(imageId);
minRaw = Number((image as any)?.minPixelValue);
maxRaw = Number((image as any)?.maxPixelValue);
slope = Number((image as any)?.slope ?? slope);
intercept = Number((image as any)?.intercept ?? intercept);
} catch (err) {
// Keep metadata-derived fallbacks below.
}
}
const centerMeta = Array.isArray(voiMeta.windowCenter) ? voiMeta.windowCenter[0] : voiMeta.windowCenter;
const widthMeta = Array.isArray(voiMeta.windowWidth) ? voiMeta.windowWidth[0] : voiMeta.windowWidth;
if (Number.isFinite(minRaw) && Number.isFinite(maxRaw) && maxRaw > minRaw) {
viewport.setProperties({
voiRange: {
lower: minRaw * slope + intercept,
upper: maxRaw * slope + intercept,
},
});
} else if (Number.isFinite(Number(centerMeta)) && Number.isFinite(Number(widthMeta)) && Number(widthMeta) > 0) {
const center = Number(centerMeta);
const width = Number(widthMeta);
viewport.setProperties({
voiRange: {
lower: center - width / 2,
upper: center + width / 2,
},
});
} else if (defaultVoi) {
viewport.setProperties({ voiRange: defaultVoi });
} else {
applyDefaultWindowLevel(viewportIdx);
return;
}
viewport.render();
updateOverlay(viewportIdx, viewport);
})().catch(() => {
applyDefaultWindowLevel(viewportIdx);
});
}, [applyDefaultWindowLevel, updateOverlay, viewportImageIds]);
const applyPresetOption = useCallback((viewportIdx: number, preset: PresetOption) => {
if (preset.action === "reset") {
applyDefaultWindowLevel(viewportIdx, true);
return;
}
if (preset.action === "autolevel") {
handleAutoLevel(viewportIdx);
return;
}
const wlPreset = preset as Extract<PresetOption, { action: "preset" }>;
handleApplyPreset(viewportIdx, wlPreset.center, wlPreset.width);
}, [applyDefaultWindowLevel, handleApplyPreset, handleAutoLevel]);
useEffect(() => {
const handlePresetHotkey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target) {
const tag = target.tagName?.toLowerCase();
const editable = target.getAttribute("contenteditable") === "true";
if (tag === "input" || tag === "textarea" || tag === "select" || editable) return;
}
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (activeViewport === null) return;
const modality = imageInfos[activeViewport]?.modality;
const options = getPresetOptionsForModality(modality);
if (e.key === "0") {
const resetOption = options.find((option) => option.action === "reset");
if (resetOption) {
applyPresetOption(activeViewport, resetOption);
e.preventDefault();
}
return;
}
if (!/^[1-9]$/.test(e.key)) return;
const presetIndex = Number(e.key) - 1;
const properPresets = options.filter((option): option is Extract<PresetOption, { action: "preset" }> => option.action === "preset");
if (presetIndex >= properPresets.length) return;
applyPresetOption(activeViewport, properPresets[presetIndex]);
e.preventDefault();
};
window.addEventListener("keydown", handlePresetHotkey);
return () => window.removeEventListener("keydown", handlePresetHotkey);
}, [activeViewport, applyPresetOption, imageInfos]);
const toggleViewerFullscreen = useCallback(() => {
const root = appRootRef.current;
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();
}
}, []);
// Update overlay and preset state arrays when grid changes
useEffect(() => {
setImageInfos(Array(viewportGrid.rows * viewportGrid.cols).fill({
index: 0,
total: 0,
windowCenter: "",
windowWidth: "",
slicePosition: "",
modality: "",
orientation: "",
resolution: "",
spacing: "",
focalPoint: "",
}));
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 preset menus when clicking outside
useEffect(() => {
if (!presetsOpenArr.some(Boolean)) return;
const handleClick = (e: MouseEvent) => {
const menu = document.querySelector(".preset-menu");
if (menu && !menu.contains(e.target as Node)) {
setPresetsOpenArr(prev => prev.map(() => false));
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [presetsOpenArr]);
useEffect(() => {
if (!viewportMenuOpen) return;
const handleOutsideClick = (e: MouseEvent) => {
const host = topControlsRef.current;
if (host && !host.contains(e.target as Node)) {
setViewportMenuOpen(false);
setTopControlsPeek(false);
}
};
document.addEventListener("mousedown", handleOutsideClick);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, [viewportMenuOpen]);
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 = stripWadouriPrefix(firstId);
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 clearViewer() {
const renderingEngine = renderingEngineRef.current;
// Disable viewports so stale frames are removed from the DOM/canvas.
if (renderingEngine) {
for (let i = 0; i < MAX_VIEWPORTS; i++) {
const viewportId = `CT_${i}`;
try {
const viewport = renderingEngine.getViewport(viewportId);
if (viewport) {
renderingEngine.disableElement(viewportId);
}
} catch (e) {
// Ignore missing/disabled viewport errors.
}
}
}
// Clear per-viewport DOM state so they can be re-enabled cleanly later.
for (let i = 0; i < MAX_VIEWPORTS; i++) {
const element = viewportRefs.current[i];
if (!element) continue;
if (element._resizeObserver) {
try {
element._resizeObserver.disconnect();
} catch (e) {
// Ignore observer teardown issues.
}
element._resizeObserver = undefined;
}
element._cornerstoneEnabled = false;
element._lastViewportType = undefined;
element._overlayListener = undefined;
element.innerHTML = '';
}
if (cornerstoneTools?.annotation?.state?.removeAllAnnotations) {
cornerstoneTools.annotation.state.removeAllAnnotations();
}
setAvailableStacks([]);
setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
setSelectedStackIdx(Array(MAX_VIEWPORTS).fill(0));
setViewportModes(Array(MAX_VIEWPORTS).fill('stack'));
setStackThumbnails({});
thumbRequestedRef.current = {};
setStackOrderModified(false);
setActiveViewport(0);
}
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 => stripWadouriPrefix(id));
const normTarget = stripWadouriPrefix(imageId);
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[`clearViewer_${apiKey}`] = clearViewer;
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 = stripWadouriPrefix(imageIds[0]);
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;
}
// First pass: set stacks/volumes and stack positions so viewports have image resources
for (let i = 0; i < numActive; i++) {
console.log("Setting stack/volume 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") {
try {
const volumeId = `myVolumeId_${i}`;
const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: importedImageIds });
volume.load();
viewport.setVolumes([{ volumeId }]);
viewport.render();
} catch (e) {
console.warn("Failed to create/load volume for viewport", i, e);
}
// Best-effort orientation restore
if (state.orientations && state.orientations[i] && typeof viewport.setOrientation === "function") {
try { viewport.setOrientation(state.orientations[i]); viewport.render(); } catch (e) { /* ignore */ }
}
if (state.volumeSliceIndices) {
setTimeout(() => { try { csUtilities.jumpToSlice(viewport.element, { imageIndex: state.volumeSliceIndices[i] }); } catch (e) { } }, 100);
}
} else {
try { if (viewport.setStack) { viewport.setStack(importedImageIds); viewport.render(); } } catch (e) { console.warn('Failed to set stack for viewport', i, e); }
if (state.stackPos && typeof state.stackPos[i] === "number" && typeof viewport.setImageIdIndex === "function") {
try { if (csUtilities && csUtilities.jumpToSlice) csUtilities.jumpToSlice(viewport.element, { imageIndex: state.stackPos[i] }); else viewport.setImageIdIndex(state.stackPos[i]); } catch (e) { /* ignore */ }
}
}
}
// Wait briefly to allow viewports to initialize GPU/actor resources
const readyDelayMs = 1500;
console.log(`Waiting ${readyDelayMs}ms before applying properties/camera to viewports`);
await new Promise((res) => setTimeout(res, readyDelayMs));
// Second pass: apply renderer-sensitive properties (colormap, VOI) and camera
for (let i = 0; i < numActive; i++) {
console.log("Applying props/camera 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;
// Restore properties with sanitization + retry
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) {
const cm = propsToRestore.colormap;
if (typeof cm === 'string') {
// keep
} else if (typeof cm === 'object') {
const safe: any = {};
if (typeof cm.name === 'string') safe.name = cm.name;
if (cm.opacity !== undefined && typeof cm.opacity === 'number') safe.opacity = cm.opacity;
if (Object.keys(safe).length === 0) delete propsToRestore.colormap; else propsToRestore.colormap = safe;
} else {
delete propsToRestore.colormap;
}
}
try {
viewport.setProperties(propsToRestore);
viewport.render();
} catch (err) {
console.warn('viewport.setProperties failed, retrying without colormap', err);
try { const noColormap = { ...propsToRestore }; delete noColormap.colormap; viewport.setProperties(noColormap); viewport.render(); } catch (err2) { console.error('Retrying viewport.setProperties without colormap also failed', err2); }
}
}
// Restore camera (best-effort)
if (state.cam && state.cam[i] && typeof viewport.setCamera === 'function') {
try { viewport.setCamera(state.cam[i]); viewport.render(); } catch (e) { /* ignore */ }
}
}
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()
// Add defensive guards and queueing so callers can call importAnnotations before the
// viewer/annotation state is fully initialized in rare race conditions.
(window as any).__pendingImportAnnotations = (window as any).__pendingImportAnnotations || {};
const flushPending = (key: string) => {
try {
const pending = (window as any).__pendingImportAnnotations && (window as any).__pendingImportAnnotations[key];
if (!pending || !pending.length) return;
const annotationState = cornerstoneTools?.annotation?.state;
if (!annotationState || typeof annotationState.addAnnotation !== 'function') return;
// Drain the queue
while (((window as any).__pendingImportAnnotations[key] || []).length) {
const annotationData = ((window as any).__pendingImportAnnotations[key] || []).shift();
if (!annotationData) continue;
if (Array.isArray(annotationData)) {
annotationData.forEach(ann => {
try { annotationState.addAnnotation(ann, ann.annotationUID); } catch (err) { console.error('Failed to add queued annotation', err, ann); }
});
}
}
if (renderingEngineRef.current) renderingEngineRef.current.render();
} catch (err) {
console.error('Error flushing pending annotations:', err);
}
};
window[`importAnnotations_${apiKey}`] = (json: string) => {
try {
const annotationData = typeof json === "string" ? JSON.parse(json) : json;
const annotationState = cornerstoneTools?.annotation?.state;
if (!annotationState || typeof annotationState.addAnnotation !== 'function') {
// Queue the annotations until the annotation state is ready
(window as any).__pendingImportAnnotations[apiKey] = (window as any).__pendingImportAnnotations[apiKey] || [];
(window as any).__pendingImportAnnotations[apiKey].push(annotationData);
console.warn('importAnnotations: annotation state not ready, queued import', apiKey);
// schedule a flush attempt
setTimeout(() => flushPending(apiKey), 500);
return;
}
// Remove all existing annotations if needed
if (annotationState.removeAllAnnotations) {
try { annotationState.removeAllAnnotations(); } catch (err) { console.warn('removeAllAnnotations failed', err); }
}
if (Array.isArray(annotationData)) {
annotationData.forEach(ann => {
try { annotationState.addAnnotation(ann, ann.annotationUID); } catch (err) { console.error('Failed to add annotation', err, ann); }
});
}
if (renderingEngineRef.current) {
renderingEngineRef.current.render();
}
} catch (e) {
console.error("Failed to import annotations:", e, { apiKey, cornerstoneTools: !!cornerstoneTools, renderingEngine: !!renderingEngineRef.current });
try { alert("Failed to import annotations: " + e); } catch (ignored) {}
}
// Attempt to flush any queued imports (in case multiple imports were queued)
try { flushPending(apiKey); } catch (ignored) {}
};
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[`clearViewer_${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;
const currentPreviewZone = dropPreview?.viewportIdx === i ? dropPreview.zone : null;
const resolvedPreviewZone = currentPreviewZone ? resolveDropZone(currentPreviewZone) : null;
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)}
onDragEnter={e => {
const idxStr = e.dataTransfer.getData('text/stack-idx');
if (!idxStr) return;
e.preventDefault();
const zone = getDropZoneFromPointer(e);
setDropPreview({ viewportIdx: i, zone });
}}
onDragOver={e => {
const idxStr = e.dataTransfer.getData('text/stack-idx');
if (!idxStr) return;
e.preventDefault();
const zone = getDropZoneFromPointer(e);
const resolved = resolveDropZone(zone);
setDropPreview({ viewportIdx: i, zone });
e.dataTransfer.dropEffect = resolved === zone ? 'copy' : 'move';
}}
onDragLeave={e => {
if (dropPreview?.viewportIdx !== i) return;
const nextTarget = e.relatedTarget as Node | null;
if (nextTarget && e.currentTarget.contains(nextTarget)) return;
setDropPreview(null);
}}
onDrop={e => {
e.preventDefault();
const idxStr = e.dataTransfer.getData('text/stack-idx');
const stackIdx = parseInt(idxStr, 10);
if (!isNaN(stackIdx)) {
const zone = getDropZoneFromPointer(e);
applyStackDrop(i, stackIdx, zone).catch((err) => {
console.error("Failed to apply dropped stack:", err);
});
}
setDropPreview(null);
setDraggedStackIdx(null);
}}
>
{draggedStackIdx !== null && currentPreviewZone && (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 19,
pointerEvents: "none",
background: "rgba(0,0,0,0.08)",
}}
>
<div
style={{
position: "absolute",
left: resolvedPreviewZone === "left" ? 0 : resolvedPreviewZone === "right" ? "50%" : "20%",
top: resolvedPreviewZone === "top" ? 0 : resolvedPreviewZone === "bottom" ? "50%" : "20%",
width: resolvedPreviewZone === "left" || resolvedPreviewZone === "right" ? "50%" : "60%",
height: resolvedPreviewZone === "top" || resolvedPreviewZone === "bottom" ? "50%" : "60%",
border: "2px solid #90caf9",
boxShadow: "0 0 0 9999px rgba(17, 29, 45, 0.22)",
background: "rgba(25, 118, 210, 0.28)",
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 12,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: 0.4,
}}
>
{resolvedPreviewZone === "center" ? "Load Here" : `Split ${resolvedPreviewZone}`}
</div>
</div>
)}
<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 }} title={ann.metadata.toolName || 'Annotation'}>
{selectedAnnUID === ann.annotationUID ? "★ " : ""}
<span style={{ fontSize: 14, lineHeight: 1 }}>{getToolIcon(ann.metadata.toolName)}</span>
</span>
<button
style={{
background: "transparent",
color: "#f44336",
border: "1px solid #f44336",
borderRadius: 3,
width: 16,
height: 16,
fontSize: 10,
lineHeight: 1,
cursor: "pointer",
marginLeft: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: 0.95,
padding: 0,
}}
title="Delete this annotation"
aria-label="Delete annotation"
onClick={e => {
e.stopPropagation();
handleDeleteAnnotation(i, ann.annotationUID, currentAnnotations);
}}
>
<span aria-hidden="true"></span>
</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>
{volumeDiagnosticsEnabled && (
<>
<div>Ori: {imageInfos[i]?.orientation || "N/A"}</div>
{imageInfos[i]?.resolution && <div>Res: {imageInfos[i]?.resolution}</div>}
{imageInfos[i]?.spacing && <div>Spacing: {imageInfos[i]?.spacing}</div>}
{imageInfos[i]?.focalPoint && <div>Pos: {imageInfos[i]?.focalPoint}</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}
{volumeDiagnosticsEnabled && (
<>
<br />
Ori: {imageInfos[i]?.orientation || "N/A"}
{imageInfos[i]?.resolution && (
<>
<br />
Res: {imageInfos[i]?.resolution}
</>
)}
{imageInfos[i]?.spacing && (
<>
<br />
Spacing: {imageInfos[i]?.spacing}
</>
)}
{imageInfos[i]?.focalPoint && (
<>
<br />
Pos: {imageInfos[i]?.focalPoint}
</>
)}
</>
)}
</div>
)}
</div>
{/* Preset menu (bottom right of viewport) */}
<div
style={{
position: "absolute",
right: 8,
bottom: 8,
zIndex: 110, // above the bottom study selector (zIndex:20)
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 ({imageInfos[i]?.modality || "Unknown"})
<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: 111,
pointerEvents: "auto", // <-- ensure pointer events are enabled
}}
onClick={e => e.stopPropagation()} // <-- prevent bubbling to viewport
>
{(() => {
const options = getPresetOptionsForModality(imageInfos[i]?.modality);
let presetOrdinal = 0;
return options.map((preset) => {
let hotkeyHint = "";
if (preset.action === "reset") {
hotkeyHint = "0";
} else if (preset.action === "preset") {
presetOrdinal += 1;
hotkeyHint = String(presetOrdinal);
}
return (
<button
key={preset.name}
title={hotkeyHint ? `Shortcut: ${hotkeyHint}` : undefined}
style={{
display: "block",
width: "100%",
margin: "2px 0",
background: preset.action === "preset" ? "#333" : "#444",
color: "#fff",
border: "none",
borderRadius: "3px",
cursor: "pointer",
padding: preset.action === "preset" ? "4px 0" : "6px 0",
fontSize: "13px",
opacity: 0.9,
fontWeight: preset.action === "preset" ? "normal" : "600",
}}
onClick={e => {
e.stopPropagation(); // <-- prevent bubbling to viewport
applyPresetOption(i, preset);
setPresetsOpenArr(prev => {
const next = [...prev];
next[i] = false;
return next;
});
}}
>
{preset.name}{hotkeyHint ? ` (${hotkeyHint})` : ""}
</button>
);
});
})()}
</div>
)}
</div>
</div>
{/* Position bar inside each viewport */}
{viewportModes[i] === "stack" && viewportImageIds[i] && (
<div
style={{
position: "absolute",
top: "10%",
right: 8,
width: "14px",
height: "80%",
maxHeight: "100%",
minHeight: 0,
zIndex: 35,
display: viewportImageIds[i].length > 0 ? "flex" : "none",
alignItems: "flex-start",
justifyContent: "center",
pointerEvents: "auto",
flexDirection: "column",
padding: 0,
}}
>
{(() => {
const imageIds = viewportImageIds[i] || [];
const total = imageIds.length;
if (total === 0) return null;
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
const currentIdx = viewport && typeof viewport.getCurrentImageIdIndex === "function"
? viewport.getCurrentImageIdIndex()
: -1;
const viewedSet = new Set(viewedIndicesByViewport[i] || []);
const loadedFlags = imageIds.map((id) => {
if (id.startsWith("external:")) return true;
return !!metaData.get("imagePlaneModule", id) || !!metaData.get("generalSeriesModule", id);
});
const loadedCount = loadedFlags.reduce((sum, loaded) => sum + (loaded ? 1 : 0), 0);
const viewedCount = imageIds.reduce((sum, _id, idx) => sum + (viewedSet.has(idx) ? 1 : 0), 0);
const segmentCount = Math.min(total, 180);
const segments = Array.from({ length: segmentCount }, () => ({ loaded: false, viewed: false, current: false }));
for (let idx = 0; idx < total; idx++) {
const segmentIdx = total === 1
? 0
: Math.min(segmentCount - 1, Math.floor((idx / (total - 1)) * (segmentCount - 1)));
if (loadedFlags[idx]) segments[segmentIdx].loaded = true;
if (viewedSet.has(idx)) segments[segmentIdx].viewed = true;
if (idx === currentIdx) segments[segmentIdx].current = true;
}
return (
<div
style={{
width: "10px",
height: "100%",
maxHeight: "100%",
minHeight: 0,
borderRadius: "6px",
background: "#191919",
position: "relative",
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
boxShadow: "0 0 8px #2196f3cc",
overflow: "hidden",
cursor: "pointer",
}}
title={`Loaded ${loadedCount}/${total} • Viewed ${viewedCount}/${total}`}
onClick={(e) => {
const bar = e.currentTarget as HTMLDivElement;
const rect = bar.getBoundingClientRect();
const y = Math.min(rect.height, Math.max(0, e.clientY - rect.top));
const ratio = rect.height > 0 ? y / rect.height : 0;
const targetIndex = Math.min(total - 1, Math.max(0, Math.round(ratio * (total - 1))));
const targetViewport = renderingEngineRef.current?.getViewport(`CT_${i}`);
if (
targetViewport &&
typeof targetViewport.setImageIdIndex === "function" &&
viewportModes[i] === "stack"
) {
csUtilities.jumpToSlice(targetViewport.element, { imageIndex: targetIndex });
updateOverlay(i, targetViewport);
}
}}
>
{segments.map((segment, idx) => {
let background = "#3f3f3f";
if (segment.loaded) background = "#2e7d32";
if (segment.viewed) background = "#1565c0";
if (segment.current) background = "#fbc02d";
return (
<div
key={idx}
style={{
width: "100%",
height: `${100 / segmentCount}%`,
minHeight: 1,
background,
opacity: segment.loaded || segment.viewed || segment.current ? 0.95 : 0.45,
borderBottom: idx < segmentCount - 1 ? "1px solid #222" : "none",
pointerEvents: "none",
}}
/>
);
})}
{currentIdx >= 0 && total > segmentCount && (
<div
style={{
position: "absolute",
left: 0,
right: 0,
top: `${(currentIdx / Math.max(1, total - 1)) * 100}%`,
height: 2,
background: "#ffeb3b",
boxShadow: "0 0 4px #ffeb3b",
transform: "translateY(-1px)",
pointerEvents: "none",
}}
/>
)}
</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) => {
// Prevent the wheel from bubbling up to the viewport (which listens for wheel
// to perform stack scrolling). Instead, scroll the dropdown itself so the
// user can use the mouse wheel to navigate the list.
try {
e.stopPropagation();
} catch (err) {
// ignore
}
// Prevent page/parent default scrolling and manually adjust scrollTop so we
// don't rely on the parent's wheel handlers.
try {
e.preventDefault();
} catch (err) {
// ignore
}
const el = e.currentTarget as HTMLElement | null;
if (el) {
// React's WheelEvent type differs from the DOM WheelEvent; safely
// read deltaY via any to avoid casting issues.
const delta = (e as any).deltaY ?? ((e.nativeEvent as any)?.deltaY ?? 0);
el.scrollTop += delta;
}
}}
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..."
: loadingState === "displayset"
? "Loading Display Set..."
: "Loading Viewer State..."}
</div>
)}
{/* Graphics error banner (shows when we detect a problematic WebGL release error) */}
{graphicsErrorVisible && (
<div
style={{
position: 'fixed',
top: 12,
left: 12,
right: 12,
zIndex: 10000,
background: '#fff3cd',
color: '#856404',
border: '1px solid #ffeeba',
padding: '10px 14px',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
}}
>
<div style={{ flex: 1, fontSize: 14 }}>
<strong>Graphics error:</strong> The viewer encountered a WebGL/graphics error and may be unstable. Please reload the page.
{graphicsErrorMessage ? (<div style={{ marginTop: 6, fontSize: 12, color: '#6b4f00' }}>{graphicsErrorMessage}</div>) : null}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button onClick={() => window.location.reload()} style={{ padding: '6px 10px' }}>Reload</button>
<button onClick={() => setGraphicsErrorVisible(false)} style={{ padding: '6px 10px' }}>Dismiss</button>
</div>
</div>
)}
{/* top stack bar removed; use right-side panel instead */}
{/* Right-side stack panel is implemented in the main content flex container below */}
{/* Open stacks button: compact and lower z-index so it does not cover modals/controls */}
{!stackPanelOpen && (
<button
onClick={() => setStackPanelOpen(true)}
aria-pressed={false}
title="Open stacks"
style={{
position: 'absolute',
right: 8,
top: 76,
zIndex: 120,
width: 24,
height: 42,
background: '#1976d2',
color: '#fff',
border: 'none',
borderRadius: 5,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
pointerEvents: 'auto'
}}
>
</button>
)}
<div>
{/* Side menu toggle button */}
<button
style={{
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
left: sideMenuButtonLeft,
zIndex: 200,
width: SIDE_MENU_BUTTON_SIZE,
height: SIDE_MENU_BUTTON_SIZE,
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: SIDE_MENU_WIDTH,
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>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}>
<label style={{ fontSize: 13 }}><input type="radio" name="folderLoadMode" value="add" checked={folderLoadMode==='add'} onChange={()=>setFolderLoadMode('add')} /> Add (append)</label>
<label style={{ fontSize: 13 }}><input type="radio" name="folderLoadMode" value="replace" checked={folderLoadMode==='replace'} onChange={()=>setFolderLoadMode('replace')} /> Replace all</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={handleFolderPick}
>
Load DICOM Folder
</button>
<input
ref={folderInputRef}
type="file"
style={{ display: "none" }}
multiple
// Directory attribute for recursive folder selection (webkit based browsers)
// Note: keep accept off here so folder picker works reliably; filtering happens after selection
onChange={handleFilesSelected}
/>
{/* Keep original file input for non-directory file selection if used elsewhere */}
<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 (Shift always works)"
: "Enable Alt Key for Reference Cursors (Shift always works)"}
</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>
<button
style={{
padding: "10px 18px",
background: volumeDiagnosticsEnabled ? "#1976d2" : "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginBottom: "18px",
width: "100%",
}}
onClick={() => setVolumeDiagnosticsEnabled((prev) => !prev)}
>
{volumeDiagnosticsEnabled ? "Disable" : "Enable"} Volume Diagnostics
</button>
{/* Add more menu items here if needed */}
</div>
</div>
)}
</div>
{/* Viewport grid menu at top center */}
<div
ref={topControlsRef}
className="grid-menu-hover-container"
style={{
position: "absolute",
top: viewportMenuOpen || topControlsPeek ? 8 : -18,
left: "50%",
transform: "translateX(-50%)",
zIndex: 100,
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
gap: 8,
width: "auto",
pointerEvents: "auto",
transition: "top 180ms ease",
paddingTop: 28,
marginTop: -28,
paddingBottom: 12,
}}
onMouseEnter={() => setTopControlsPeek(true)}
onMouseLeave={() => {
if (!viewportMenuOpen) setTopControlsPeek(false);
}}
>
<button
style={{
height: 40,
padding: "6px 12px 6px 10px",
background: "#222",
color: "#fff",
border: "none",
borderRadius: "0 0 4px 4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "14px",
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
display: "inline-flex",
alignItems: "center",
gap: 8,
}}
title="Toggle viewer fullscreen"
onFocus={() => setTopControlsPeek(true)}
onClick={() => {
setTopControlsPeek(true);
toggleViewerFullscreen();
}}
>
<span style={{ fontSize: 16, lineHeight: 1 }}></span>
<span>Fullscreen</span>
</button>
<div style={{ position: "relative", display: "flex", flexDirection: "column", alignItems: "stretch" }}>
<button
className="grid-menu-btn"
style={{
height: 40,
padding: "6px 14px 6px 10px",
background: "#222",
color: "#fff",
border: "none",
borderRadius: "0 0 4px 4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "14px",
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
display: "inline-flex",
alignItems: "center",
gap: 10,
justifyContent: "space-between",
minWidth: 136,
}}
onFocus={() => setTopControlsPeek(true)}
onClick={() => {
setTopControlsPeek(true);
setViewportMenuOpen(open => !open);
}}
tabIndex={0}
>
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<span
style={{
display: "grid",
gridTemplateColumns: `repeat(${viewportGrid.cols}, 5px)`,
gridTemplateRows: `repeat(${viewportGrid.rows}, 5px)`,
gap: 2,
padding: 1,
}}
>
{Array.from({ length: viewportGrid.rows * viewportGrid.cols }).map((_, idx) => (
<span
key={idx}
style={{
width: 5,
height: 5,
borderRadius: 1,
background: "#90caf9",
display: "block",
}}
/>
))}
</span>
<span>{viewportGrid.rows}x{viewportGrid.cols}</span>
</span>
<span style={{ opacity: 0.8 }}>{viewportMenuOpen ? "▲" : "▼"}</span>
</button>
{viewportMenuOpen && (
<div
style={{
position: "absolute",
top: "calc(100% + 8px)",
right: 0,
background: "#222",
color: "#fff",
borderRadius: "6px",
boxShadow: "0 2px 12px rgba(0,0,0,0.4)",
padding: "12px",
display: "grid",
gridTemplateColumns: "repeat(3, minmax(68px, 1fr))",
gap: "8px",
zIndex: 200,
pointerEvents: "auto",
}}
onMouseDown={(e) => e.stopPropagation()}
>
{gridOptions.map(opt => (
<button
key={`${opt.rows}x${opt.cols}`}
style={{
minWidth: "68px",
height: "54px",
background: (opt.rows === viewportGrid.rows && opt.cols === viewportGrid.cols) ? "#444" : "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "13px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 6,
}}
onClick={() => {
saveVisibleViewportState();
setViewportGrid({ rows: opt.rows, cols: opt.cols })
setViewportMenuOpen(false)
setTopControlsPeek(false)
}}
>
<span
style={{
display: "grid",
gridTemplateColumns: `repeat(${opt.cols}, 6px)`,
gridTemplateRows: `repeat(${opt.rows}, 6px)`,
gap: 2,
}}
>
{Array.from({ length: opt.rows * opt.cols }).map((_, idx) => (
<span
key={idx}
style={{
width: 6,
height: 6,
borderRadius: 1,
background: "#90caf9",
display: "block",
}}
/>
))}
</span>
<span>{opt.rows}x{opt.cols}</span>
</button>
))}
</div>
)}
</div>
</div>
{/* Main content area: viewports and right-side panel in a row so panel pushes content */}
<div style={{ display: 'flex', flexDirection: 'row', height: 'calc(100% - 48px)', gap: 8 }}>
{/* Viewport grid container — flex:1 so it shrinks when panel takes space */}
<div
style={{
flex: 1,
display: 'grid',
margin: 3,
gridTemplateRows: `repeat(${viewportGrid.rows}, 1fr)`,
gridTemplateColumns: `repeat(${viewportGrid.cols}, 1fr)`,
gap: '5px',
minHeight: 0,
minWidth: 0,
zIndex: 1,
position: 'relative',
}}
>
{viewports}
{/* Settings button positioned relative to the viewport area so it doesn't shift when the right panel opens */}
<button
style={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 2600,
padding: '6px 12px',
background: '#222',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
opacity: 0.85,
userSelect: 'none',
}}
onClick={() => setSettingsOpen(true)}
>
Settings
</button>
</div>
{/* Right-side panel occupies layout space when visible; when closed width is 0 and it doesn't catch pointer events */}
<div style={{ width: stackPanelOpen ? stackPanelWidth : 0, transition: 'width 220ms ease', display: 'flex', flexDirection: 'column', pointerEvents: stackPanelOpen ? 'auto' : 'none', overflow: 'hidden', position: 'relative' }}>
{stackPanelOpen && (
<div
onMouseDown={beginStackPanelResize}
title="Resize stacks panel"
style={{
position: 'absolute',
left: 0,
top: 0,
width: 8,
height: '100%',
cursor: 'col-resize',
zIndex: 2800,
background: 'linear-gradient(to right, rgba(100,181,246,0.25), rgba(100,181,246,0))',
}}
/>
)}
{/* Reuse the existing right-side panel markup but ensure pointerEvents are enabled inside */}
<div style={{ pointerEvents: 'auto', width: '100%', height: '100%' }}>
{/* Panel (same content as before) */}
<div
// panel open/close is controlled via the small toggle button and pin state
style={{
height: '100%',
width: '100%',
background: '#1f2428',
color: '#fff',
boxShadow: '0 2px 14px rgba(0,0,0,0.45)',
borderLeft: '1px solid #2f3336',
transform: 'translateX(0)',
transition: 'transform 220ms ease',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', borderBottom: '1px solid #2b2f31' }}>
<div style={{ fontWeight: 700 }}>Stacks</div>
<div style={{ fontSize: 12, color: '#9bb4c8' }}>
Active VP: {activeViewport !== null ? activeViewport + 1 : '-'}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button
title="Close"
onClick={() => { setStackPanelOpen(false); }}
style={{ background: 'transparent', color: '#fff', border: 'none', cursor: 'pointer', fontSize: 16 }}
>
×
</button>
</div>
</div>
<div style={{ overflowY: 'auto', padding: 8, flex: 1 }}>
{availableStacks.length === 0 ? (
<div style={{ color: '#999', padding: 12 }}>No stacks available</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{availableStacks.map((stack, idx) => {
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;
const isSelected = selectedStackIdx.includes(idx);
const isActiveViewportStack = activeViewport !== null && selectedStackIdx[activeViewport] === idx;
// Prefer the middle image for a representative thumbnail, fallback to first
const midIndex = Math.floor(imageIds.length / 2);
const chosenImage = imageIds[midIndex] || imageIds[0];
// Remove the wadouri: scheme if present. Use replace to avoid off-by-one slice errors.
const thumbnailUrl = chosenImage ? stripWadouriPrefix(chosenImage) : null;
return (
<div
key={idx}
draggable={!invalid}
onDragStart={e => {
if (invalid) return;
setDraggedStackIdx(idx);
setDropPreview(null);
e.dataTransfer.setData('text/stack-idx', String(idx));
e.dataTransfer.effectAllowed = 'copy';
}}
onDragEnd={() => {
setDraggedStackIdx(null);
setDropPreview(null);
}}
onClick={() => {
if (invalid) return;
const targetViewport = activeViewport ?? 0;
handleLoadStack(targetViewport, idx);
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 10px',
background: isActiveViewportStack ? '#0d47a1' : (isSelected ? '#1976d2' : 'transparent'),
color: isSelected ? '#fff' : '#ddd',
border: isActiveViewportStack ? '1px solid #64b5f6' : '1px solid transparent',
borderRadius: 6,
cursor: invalid ? 'not-allowed' : 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{(() => {
const generated = stackThumbnails[idx];
// If generation explicitly failed, treat as missing so placeholder is shown
const generatedValid = generated && generated !== 'BROKEN' ? generated : null;
const src = generatedValid || thumbnailUrl || null;
if (!src) {
return (<div style={{ width: 56, height: 56, borderRadius: 4, background: '#111', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#777' }}>No Img</div>);
}
return (
<img
src={src}
alt={label}
style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 4, background: '#111' }}
onError={(e) => {
console.warn('Thumbnail <img> failed to load for stack', idx, src);
// Mark thumbnail as broken so we render the placeholder instead
setStackThumbnails(prev => ({ ...prev, [idx]: 'BROKEN' }));
}}
/>
);
})()}
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontWeight: 600 }}>{label}</div>
<div style={{ fontSize: 12, opacity: 0.7 }}>{count} img{count === 1 ? '' : 's'}</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
<div style={{ fontSize: 12, opacity: 0.8 }}>{stack.studyId || ''}</div>
{isActiveViewportStack && (
<div style={{ fontSize: 11, color: '#bbdefb', fontWeight: 700 }}>ACTIVE</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
</div>
{/* duplicate Settings button removed; the Settings button is now placed inside the viewport area */}
{/* 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} title={opt.label}>{`${opt.icon ? `${opt.icon} ` : ""}${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} title={opt.label}>{`${opt.icon ? `${opt.icon} ` : ""}${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} title={opt.label}>{`${opt.icon ? `${opt.icon} ` : ""}${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} title={opt.label}>{`${opt.icon ? `${opt.icon} ` : ""}${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
}
}