Compare commits

...
6 Commits
Author SHA1 Message Date
Ross 1fd3020f0c improve export schema 2025-06-01 22:17:41 +01:00
Ross 313c7a9e9a basic viewport save and restore fnuctiontality 2025-05-31 21:29:37 +01:00
Ross 40be13b51a some visual tidy up 2025-05-31 15:28:40 +01:00
Ross 4875b95ab2 more improvements 2025-05-31 15:14:46 +01:00
Ross c7728bf1a2 further improvements 2025-05-31 11:24:20 +01:00
Ross c90c280255 start fixing ts errors 2025-05-31 10:23:52 +01:00
4 changed files with 815 additions and 255 deletions
+53 -1
View File
@@ -7,7 +7,59 @@
<title>Vite + React + TS</title> <title>Vite + React + TS</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root" style="max-height:500px; height:500px; overflow:auto;"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<input type="file" id="dicomInput" multiple />
<button onclick="sendToViewer()">Send to Viewer</button>
<button onclick="testLoadWadouriStack()">Test Load wadouri Stack</button>
<script>
function sendToViewer() {
const files = document.getElementById('dicomInput').files;
if (window.loadDicomStackFromFiles) {
window.loadDicomStackFromFiles(files);
} else {
alert("Viewer API not ready");
}
}
function testLoadWadouriStack() {
const urls = [
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_757_XQQyovo.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_766_8fWI0Qc.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_774_c8IpxUu.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_758_6WKI7ws.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_767_TduVOb8.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_775_7zWneQ0.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_759_BtKzgS5.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_768_RdvtM8K.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_776_LhVY3Ty.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_760_a8CiR4J.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_769_YHyJ1l6.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_777_8IFYJmL.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_761_vyvO1EN.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_770_vQwi4LW.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_778_BwGAMOR.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_762_DhSgQsm.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_771_xifitCl.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_779_LyA1zYn.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_763_aVjff3H.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_772_cDQZtxQ.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_780_qaZqsqg.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_764_dDoeldL.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_773_93Hvedg.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_781_bEx9Owg.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_765_qmd5mYJ.dcm"
];
const wadouriList = urls.map(url => "wadouri:" + url);
if (window.loadDicomStackFromWadouriList) {
window.loadDicomStackFromWadouriList(wadouriList);
} else {
alert("Viewer API not ready");
}
}
</script>
</body> </body>
</html> </html>
+564 -54
View File
@@ -9,7 +9,6 @@ import { init as csToolsInit, addTool, ToolGroupManager, Enums as csToolsEnums,
import { LengthTool, ProbeTool, ArrowAnnotateTool, RectangleROITool, EllipticalROITool, PlanarFreehandROITool, PlanarFreehandContourSegmentationTool, SculptorTool, CrosshairsTool, ReferenceLinesTool, ReferenceCursors } 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 { init as dicomImageLoaderInit } from "@cornerstonejs/dicom-image-loader"
import * as cornerstoneTools from '@cornerstonejs/tools'; import * as cornerstoneTools from '@cornerstonejs/tools';
import { StackScrollOutOfBoundsEvent } from 'core/src/types/EventTypes';
import { segmentation, Enums as csEnums } from '@cornerstonejs/tools'; import { segmentation, Enums as csEnums } from '@cornerstonejs/tools';
import { voi } from "@cornerstonejs/tools/utilities" import { voi } from "@cornerstonejs/tools/utilities"
import { readDicomElementExplicit } from "dicom-parser" import { readDicomElementExplicit } from "dicom-parser"
@@ -18,9 +17,48 @@ import { metaData } from '@cornerstonejs/core';
import * as dicomParser from "dicom-parser"; import * as dicomParser from "dicom-parser";
import cornerstoneDICOMImageLoader from "@cornerstonejs/dicom-image-loader" import cornerstoneDICOMImageLoader from "@cornerstonejs/dicom-image-loader"
// Extend the Window interface to include cornerstoneTools
declare global {
interface Window {
cornerstoneTools: typeof cornerstoneTools;
}
}
// Add to global types:
declare global {
interface Window {
exportViewerState?: () => string;
importViewerState?: (json: string) => void;
exportAnnotations?: () => string;
importAnnotations?: (json: string) => void;
}
}
type StackEntry = { imageIds: string[], studyInstanceUID?: string };
// Add this type for clarity
declare global {
interface Window {
loadDicomStackFromFiles?: (files: FileList | File[]) => void;
}
}
declare global {
interface Window {
loadDicomStackFromWadouriList?: (wadouriImageIds: string[]) => void;
}
}
window.cornerstoneTools = cornerstoneTools; window.cornerstoneTools = cornerstoneTools;
type ViewportDiv = HTMLDivElement & {
_cornerstoneEnabled?: boolean;
_lastViewportType?: any;
_resizeObserver?: ResizeObserver;
_overlayListener?: EventListener;
};
const { MouseBindings, KeyboardBindings } = csToolsEnums; const { MouseBindings, KeyboardBindings } = csToolsEnums;
const { IMAGE_RENDERED } = Enums.Events; const { IMAGE_RENDERED } = Enums.Events;
@@ -88,6 +126,8 @@ function App() {
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [fullscreenHoverIdx, setFullscreenHoverIdx] = useState<number | null>(null);
const [openDropdownIdx, setOpenDropdownIdx] = useState<number | null>(null);
// State for overlay info // State for overlay info
const [imageInfo, setImageInfo] = useState({ const [imageInfo, setImageInfo] = useState({
index: 0, index: 0,
@@ -100,7 +140,10 @@ function App() {
const renderingEngineId = "mainRenderingEngine"; const renderingEngineId = "mainRenderingEngine";
const renderingEngineRef = useRef<any>(null); const renderingEngineRef = useRef<any>(null);
const [availableStacks, setAvailableStacks] = useState<string[][]>([]); const restoringStateRef = useRef(false);
const [availableStacks, setAvailableStacks] = useState<StackEntry[]>([]);
const [activeViewport, setActiveViewport] = useState<number | null>(0); const [activeViewport, setActiveViewport] = useState<number | null>(0);
const [viewportImageIds, setViewportImageIds] = useState<string[][]>( const [viewportImageIds, setViewportImageIds] = useState<string[][]>(
() => Array(MAX_VIEWPORTS).fill([]) () => Array(MAX_VIEWPORTS).fill([])
@@ -109,20 +152,49 @@ function App() {
() => Array(MAX_VIEWPORTS).fill(0) () => Array(MAX_VIEWPORTS).fill(0)
); );
// Load available stacks on mount // Load available stacks on mount
useEffect(() => { useEffect(() => {
availableImageIds().then(stacks => { availableImageIds().then(async stacks => {
setAvailableStacks(stacks); // Try to extract StudyInstanceUID for each stack
// Optionally initialize each viewport with the first stack const stackEntries: StackEntry[] = await Promise.all(
stacks.map(async imageIds => {
let studyInstanceUID: string | undefined = undefined;
try {
// Try to fetch the first image as a blob and parse it
const firstId = imageIds[0];
if (firstId && firstId.startsWith("wadouri:")) {
const url = firstId.slice(8);
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
studyInstanceUID = dataSet.string('x0020000d');
}
} catch (e) {
// Ignore errors, fallback to undefined
}
return { imageIds, studyInstanceUID };
})
);
setAvailableStacks(stackEntries);
//setAvailableStacks(stacks.map(imageIds => ({ imageIds, studyInstanceUID: undefined })));
setLoadedImageIdsAndCacheMeta(stacks[0]); setLoadedImageIdsAndCacheMeta(stacks[0]);
setViewportImageIds(Array(MAX_VIEWPORTS).fill(stacks[0] || [])); setViewportImageIds(Array(MAX_VIEWPORTS).fill(stacks[0] || []));
}); });
}, []); }, []);
const handleLoadStack = async (viewportIdx: number, stackIdx: number) => { const handleLoadStack = async (viewportIdx: number, stackIdx: number) => {
const stack = availableStacks[stackIdx]; const stackObj = availableStacks[stackIdx];
if (!stackObj) return;
const stack = stackObj.imageIds;
// Always revert to stack mode when loading a new stack
setViewportModes(prev => {
const next = [...prev];
next[viewportIdx] = 'stack';
return next;
});
if (!stack) return;
setViewportImageIds(prev => { setViewportImageIds(prev => {
const next = [...prev]; const next = [...prev];
next[viewportIdx] = stack; next[viewportIdx] = stack;
@@ -142,6 +214,7 @@ function App() {
const [referenceLinesEnabled, setReferenceLinesEnabled] = useState(true); const [referenceLinesEnabled, setReferenceLinesEnabled] = useState(true);
const [showAdvancedMouseBindings, setShowAdvancedMouseBindings] = useState(false); const [showAdvancedMouseBindings, setShowAdvancedMouseBindings] = useState(false);
const [showAdvancedCtrlMouseBindings, setShowAdvancedCtrlMouseBindings] = useState(false);
const sortViewportImageIdsBySliceLocation = () => { const sortViewportImageIdsBySliceLocation = () => {
setViewportImageIds(prev => { setViewportImageIds(prev => {
@@ -194,7 +267,10 @@ function App() {
if (url.startsWith("blob:") || url.startsWith("/")) { if (url.startsWith("blob:") || url.startsWith("/")) {
if (cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager) { if (cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager) {
// If load returns a promise, collect it // If load returns a promise, collect it
const result = cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager.load(url); // @ts-ignore
const result = cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager.load(
url,
);
if (result && typeof result.then === "function") { if (result && typeof result.then === "function") {
loadPromises.push(result); loadPromises.push(result);
} }
@@ -222,7 +298,7 @@ function App() {
const [viewportMenuOpen, setViewportMenuOpen] = useState(false) const [viewportMenuOpen, setViewportMenuOpen] = useState(false)
// Store refs for each viewport // Store refs for each viewport
const viewportRefs = useRef<Array<HTMLDivElement | null>>([]) const viewportRefs = useRef<Array<ViewportDiv | null>>([]);
// Store the properties and scroll position of existing viewports before grid change // Store the properties and scroll position of existing viewports before grid change
const prevViewportProps = useRef<Record<number, any>>({}); const prevViewportProps = useRef<Record<number, any>>({});
@@ -358,6 +434,21 @@ function App() {
setMetaModalOpen(true); setMetaModalOpen(true);
}; };
// Inside your App component:
useEffect(() => {
window.loadDicomStackFromFiles = (files: FileList | File[]) => {
// Convert FileList to array if needed
const fileArray = Array.from(files);
// Your logic to handle the files and load them as a stack
// For example, call your existing handler:
handleFilesSelected({ target: { files: fileArray } } as any);
};
// Cleanup on unmount
return () => {
window.loadDicomStackFromFiles = undefined;
};
}, []);
const [altPressed, setAltPressed] = useState(false); const [altPressed, setAltPressed] = useState(false);
useEffect(() => { useEffect(() => {
@@ -463,23 +554,38 @@ function App() {
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
// Handler for file input change // Handler for file input change
const handleFilesSelected = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleFilesSelected = async (event: React.ChangeEvent<HTMLInputElement> | { target: { files: File[] | FileList } }) => {
const files = event.target.files; const files = event.target.files;
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
// Reset order by slice location when new files are loaded
setOrderBySliceLocation(false); setOrderBySliceLocation(false);
// Convert FileList to Array and sort by name (optional, for series order)
const fileArray = Array.from(files).sort((a, b) => a.name.localeCompare(b.name)); const fileArray = Array.from(files).sort((a, b) => a.name.localeCompare(b.name));
let studyInstanceUID: string | undefined = undefined;
const arrayBuffer = await fileArray[0].arrayBuffer();
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
studyInstanceUID = dataSet.string('x0020000d');
console.log("StudyInstanceUID:", studyInstanceUID);
// Create imageIds for each file using the cornerstone-wado-image-loader file API
// (Assumes cornerstone-wado-image-loader is configured for file loading)
const imageIds = fileArray.map( const imageIds = fileArray.map(
file => `wadouri:${URL.createObjectURL(file)}` file => `wadouri:${URL.createObjectURL(file)}`
); );
setAvailableStacks(prev => {
const next = [...prev, { imageIds, studyInstanceUID }];
setViewportImageIds(vpPrev => {
const vpNext = [...vpPrev];
vpNext[0] = imageIds;
return vpNext;
});
setSelectedStackIdx(selPrev => {
const selNext = [...selPrev];
selNext[0] = next.length - 1;
return selNext;
});
setLoadedImageIdsAndCacheMeta(imageIds); setLoadedImageIdsAndCacheMeta(imageIds);
return next;
});
}; };
@@ -541,6 +647,8 @@ function App() {
useEffect(() => { useEffect(() => {
const setup = async () => { const setup = async () => {
console.log("setup")
// 1. Initialize Cornerstone3D and tools ONCE // 1. Initialize Cornerstone3D and tools ONCE
if (!running.current) { if (!running.current) {
running.current = true; running.current = true;
@@ -631,7 +739,7 @@ function App() {
} }
if (viewportModes[i] === 'stack') { if (viewportModes[i] === 'stack') {
if (imageIds) { if (imageIds && !restoringStateRef.current) {
viewport.setStack(imageIds); viewport.setStack(imageIds);
} }
// CrosshairsTool is not applicable for stack viewports // CrosshairsTool is not applicable for stack viewports
@@ -666,6 +774,7 @@ function App() {
} }
viewport.render(); viewport.render();
if (!restoringStateRef.current) {
// Restore previous scroll position for existing viewports only // Restore previous scroll position for existing viewports only
if ( if (
prevScrollIndices.current[i] !== undefined && prevScrollIndices.current[i] !== undefined &&
@@ -694,21 +803,23 @@ function App() {
propsToRestore.colormap = colormap; propsToRestore.colormap = colormap;
} }
setTimeout(() => { setTimeout(() => {
console.log("Restoring properties (timeout) for viewport", i, propsToRestore);
viewport.setProperties(propsToRestore); viewport.setProperties(propsToRestore);
viewport.render() viewport.render()
}, 100); }, 100);
} }
}
// Always (re-)attach overlay update for each viewport // Always (re-)attach overlay update for each viewport
if (element._overlayListener) { if ((element)._overlayListener) {
// Remove previous listener if present // Remove previous listener if present
viewport.element.removeEventListener(IMAGE_RENDERED, element._overlayListener); viewport.element.removeEventListener(IMAGE_RENDERED, (element)._overlayListener);
} }
const overlayListener = () => updateOverlay(i, viewport); const overlayListener = () => updateOverlay(i, viewport);
viewport.element.addEventListener(IMAGE_RENDERED, overlayListener); viewport.element.addEventListener(IMAGE_RENDERED, overlayListener);
element._overlayListener = overlayListener; (element)._overlayListener = overlayListener;
updateOverlay(i, viewport); updateOverlay(i, viewport);
} }
@@ -763,9 +874,9 @@ function App() {
// Cleanup: disconnect ResizeObservers // Cleanup: disconnect ResizeObservers
return () => { return () => {
viewportRefs.current.forEach(el => { viewportRefs.current.forEach(el => {
if (el && el._resizeObserver) { if (el && (el)._resizeObserver) {
el._resizeObserver.disconnect(); (el)._resizeObserver.disconnect();
delete el._resizeObserver; delete (el)._resizeObserver;
} }
}); });
cancelAnimationFrame(raf); cancelAnimationFrame(raf);
@@ -780,6 +891,8 @@ function App() {
const viewport = renderingEngine.getViewport(viewportId); const viewport = renderingEngine.getViewport(viewportId);
if (viewport && typeof viewport.resetCamera === "function") { if (viewport && typeof viewport.resetCamera === "function") {
viewport.resetCamera(); viewport.resetCamera();
console.log("Restoring viewport properties for viewport", viewportIdx);
viewport.resetProperties(); // Resets window/level and other properties viewport.resetProperties(); // Resets window/level and other properties
viewport.render(); viewport.render();
} }
@@ -793,6 +906,7 @@ function App() {
if (renderingEngine) { if (renderingEngine) {
const viewport = renderingEngine.getViewport(viewportId); const viewport = renderingEngine.getViewport(viewportId);
if (viewport && typeof viewport.setProperties === "function") { if (viewport && typeof viewport.setProperties === "function") {
console.log(`Applying preset to viewport ${viewportIdx}: center=${center}, width=${width}`);
viewport.setProperties({ viewport.setProperties({
voiRange: { voiRange: {
lower: center - width / 2, lower: center - width / 2,
@@ -853,6 +967,57 @@ function App() {
updateActiveReferenceLineViewport(mainToolGroup) updateActiveReferenceLineViewport(mainToolGroup)
}, [activeViewport, referenceLinesEnabled]); }, [activeViewport, referenceLinesEnabled]);
useEffect(() => {
if (fileInputRef.current) {
fileInputRef.current.setAttribute("webkitdirectory", "true");
fileInputRef.current.setAttribute("directory", "true");
}
}, []);
useEffect(() => {
// API: Load a stack from a list of wadouri imageIds (strings)
window.loadDicomStackFromWadouriList = async (wadouriImageIds: string[]) => {
if (!wadouriImageIds || wadouriImageIds.length === 0) return;
// Try to extract StudyInstanceUID from the first image if possible
let studyInstanceUID: string | undefined = undefined;
try {
const firstId = wadouriImageIds[0];
if (firstId && firstId.startsWith("wadouri:")) {
const url = firstId.slice(8);
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
studyInstanceUID = dataSet.string('x0020000d');
}
} catch (e) {
// Ignore errors, fallback to undefined
}
setAvailableStacks(prev => {
const next = [...prev, { imageIds: wadouriImageIds, studyInstanceUID }];
setViewportImageIds(vpPrev => {
const vpNext = [...vpPrev];
vpNext[0] = wadouriImageIds;
return vpNext;
});
setSelectedStackIdx(selPrev => {
const selNext = [...selPrev];
selNext[0] = next.length - 1;
return selNext;
});
setLoadedImageIdsAndCacheMeta(wadouriImageIds);
return next;
});
};
// Cleanup
return () => {
window.loadDicomStackFromWadouriList = undefined;
};
}, [setAvailableStacks, setViewportImageIds, setSelectedStackIdx, setLoadedImageIdsAndCacheMeta]);
useEffect(() => { useEffect(() => {
if (!orderBySliceLocation) return; if (!orderBySliceLocation) return;
@@ -906,6 +1071,180 @@ function App() {
mainToolGroup.setToolDisabled(ReferenceLinesTool.toolName) mainToolGroup.setToolDisabled(ReferenceLinesTool.toolName)
} }
} }
useEffect(() => {
// Export the current viewer state as JSON
window.exportViewerState = () => {
const numActive = viewportGrid.rows * viewportGrid.cols;
const modes = viewportModes.slice(0, numActive);
const stackIdx = selectedStackIdx.slice(0, numActive);
const stacks = availableStacks.map(s => ({
i: s.imageIds, // imageIds
u: s.studyInstanceUID // studyInstanceUID
}));
// Only keep minimal properties
const renderingEngine = renderingEngineRef.current;
const props: any[] = [];
const stackPos: number[] = [];
const cam: any[] = [];
for (let i = 0; i < numActive; i++) {
let p = null, s = null, c = null;
if (renderingEngine) {
const viewportId = `CT_${i}`;
const viewport = renderingEngine.getViewport(viewportId);
if (viewport) {
if (typeof viewport.getProperties === "function") {
const { voiRange, invert, interpolationType, VOILUTFunction, colormap } = viewport.getProperties();
p = { voiRange, invert, interpolationType, VOILUTFunction, colormap };
}
if (typeof viewport.getCurrentImageIdIndex === "function") {
s = viewport.getCurrentImageIdIndex();
}
if (typeof viewport.getCamera === "function") {
c = viewport.getCamera();
}
}
}
props.push(p);
stackPos.push(s);
cam.push(c);
}
// Use short keys for compactness
const state = {
grid: viewportGrid,
modes,
stacks,
stackIdx,
stackPos,
props,
cam,
};
return JSON.stringify(state);
};
// Import and restore a viewer state from JSON (using stack indices)
window.importViewerState = (json: string) => {
try {
restoringStateRef.current = true;
const state = typeof json === "string" ? JSON.parse(json) : json;
if (state.grid) setViewportGrid(state.grid);
if (state.modes) setViewportModes(state.modes);
if (state.stacks) setAvailableStacks(state.stacks.map((s: any) => ({
imageIds: s.i,
studyInstanceUID: s.u,
})));
if (state.stackIdx && state.stacks) {
const reconstructed = state.stackIdx.map(
(idx: number) => state.stacks[idx]?.i || []
);
setViewportImageIds(reconstructed);
setSelectedStackIdx(state.stackIdx);
}
setTimeout(() => {
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) {
restoringStateRef.current = false;
return;
}
const numActive = state.grid?.rows * state.grid?.cols || 1;
for (let i = 0; i < numActive; i++) {
const viewportId = `CT_${i}`;
const viewport = renderingEngine.getViewport(viewportId);
if (!viewport) continue;
// Restore stack position
if (
state.stackPos &&
typeof state.stackPos[i] === "number" &&
typeof viewport.setImageIdIndex === "function"
) {
if (csUtilities && csUtilities.jumpToSlice) {
csUtilities.jumpToSlice(viewport.element, { imageIndex: state.stackPos[i] });
} else {
viewport.setImageIdIndex(state.stackPos[i]);
}
}
// Restore properties
if (
state.props &&
state.props[i] &&
typeof viewport.setProperties === "function"
) {
viewport.setProperties(state.props[i]);
viewport.render();
}
// Restore camera
if (
state.cam &&
state.cam[i] &&
typeof viewport.setCamera === "function"
) {
viewport.setCamera(state.cam[i]);
viewport.render();
}
}
restoringStateRef.current = false;
}, 300);
} catch (e) {
restoringStateRef.current = false;
alert("Failed to import viewer state: " + e);
}
};
// Export all annotations using cornerstoneTools.annotation.state.getAllAnnotations()
window.exportAnnotations = () => {
try {
const allAnnotations = cornerstoneTools.annotation.state.getAllAnnotations();
return JSON.stringify(allAnnotations, null, 2);
} catch (e) {
alert("Failed to export annotations: " + e);
return "{}";
}
};
// Import and restore all annotations using cornerstoneTools.annotation.state.restoreAnnotations()
window.importAnnotations = (json: string) => {
try {
const annotationData = typeof json === "string" ? JSON.parse(json) : json;
cornerstoneTools.annotation.state.restoreAnnotations(annotationData);
if (renderingEngineRef.current) {
renderingEngineRef.current.render();
}
} catch (e) {
alert("Failed to import annotations: " + e);
}
};
// Cleanup
return () => {
window.exportViewerState = undefined;
window.importViewerState = undefined;
window.exportAnnotations = undefined;
window.importAnnotations = undefined;
};
}, [
viewportGrid,
viewportModes,
selectedStackIdx,
availableStacks,
viewportImageIds,
setViewportGrid,
setViewportModes,
setSelectedStackIdx,
setAvailableStacks,
setViewportImageIds,
]);
// Reference stack and UID for study comparison (use active viewport)
const referenceStackIdx = activeViewport !== null ? selectedStackIdx[activeViewport] : 0;
const referenceStackObj = availableStacks[referenceStackIdx];
const referenceUID = referenceStackObj?.studyInstanceUID;
const viewports = []; const viewports = [];
for (let i = 0; i < MAX_VIEWPORTS; i++) { for (let i = 0; i < MAX_VIEWPORTS; i++) {
@@ -1047,6 +1386,52 @@ function App() {
</button> </button>
</div> </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>
{/* Overlay info (bottom left of viewport) */} {/* Overlay info (bottom left of viewport) */}
<div <div
style={{ style={{
@@ -1282,9 +1667,9 @@ function App() {
padding: "2px 8px", padding: "2px 8px",
}} }}
> >
<select {/* Custom Stack Dropdown */}
value={selectedStackIdx[i] || 0} <div style={{ position: "relative" }}>
onChange={e => handleLoadStack(i, Number(e.target.value))} <button
style={{ style={{
background: "#222", background: "#222",
color: "#fff", color: "#fff",
@@ -1293,29 +1678,127 @@ function App() {
padding: "2px 8px", padding: "2px 8px",
fontSize: "13px", fontSize: "13px",
marginRight: "6px", marginRight: "6px",
}} minWidth: 120,
> textAlign: "left",
{availableStacks.map((stack, idx) => (
<option key={idx} value={idx}>
Stack {idx + 1} ({stack.length} images)
</option>
))}
</select>
<button
style={{
background: "#1976d2",
color: "#fff",
border: "none",
borderRadius: "3px",
padding: "2px 12px",
cursor: "pointer", cursor: "pointer",
fontSize: "13px",
}} }}
onClick={() => handleLoadStack(i, selectedStackIdx[i] || 0)} onClick={() => setOpenDropdownIdx(openDropdownIdx === i ? null : i)}
disabled={!availableStacks.length} //onBlur={() => setTimeout(() => setOpenDropdownIdx(null), 150)}
aria-haspopup="listbox"
aria-expanded={openDropdownIdx === i}
> >
Load Stack {/* Show current stack with indicator */}
{(() => {
const stackObj = availableStacks[selectedStackIdx[i] || 0];
const thisUID = stackObj?.studyInstanceUID;
const sameStudy = referenceUID && thisUID && referenceUID === thisUID;
const dotColor = sameStudy ? "#4caf50" : "#888";
return (
<span>
<span style={{
color: dotColor,
fontWeight: "bold",
marginRight: 6,
fontSize: "16px",
verticalAlign: "middle",
}}></span>
Stack {selectedStackIdx[i] + 1} ({stackObj?.imageIds.length || 0} images)
</span>
);
})()}
<span style={{ float: "right", fontSize: "12px" }}></span>
</button> </button>
{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: 180,
maxHeight: 220,
overflowY: "auto",
overscrollBehavior: "contain",
scrollbarWidth: "thin",
}}
role="listbox"
onMouseDown={e => e.stopPropagation()}
onWheel={e => {
// Trap scroll inside the dropdown
const target = e.currentTarget as HTMLDivElement;
const { scrollTop, scrollHeight, clientHeight } = target;
const atTop = scrollTop === 0;
const atBottom = scrollTop + clientHeight >= scrollHeight - 1;
if (
(e.deltaY < 0 && atTop) ||
(e.deltaY > 0 && atBottom)
) {
// Let the event bubble to parent (viewport)
return;
}
// Otherwise, prevent scrolling the viewport
e.stopPropagation();
e.preventDefault();
// Manually scroll the menu
target.scrollTop += e.deltaY;
}}
onBlur={e => {
const related = e.relatedTarget as Node;
const parent = e.currentTarget.parentElement;
if (!parent?.contains(related)) {
setTimeout(() => setOpenDropdownIdx(null), 100);
}
}}
>
{availableStacks.map((stackObj, idx) => {
const thisUID = stackObj.studyInstanceUID;
const sameStudy = referenceUID && thisUID && referenceUID === thisUID;
const dotColor = sameStudy ? "#4caf50" : "#888";
return (
<div
key={idx}
role="option"
aria-selected={selectedStackIdx[i] === idx}
style={{
padding: "6px 12px",
background: selectedStackIdx[i] === idx ? "#444" : "#222",
color: "#fff",
cursor: "pointer",
display: "flex",
alignItems: "center",
fontWeight: selectedStackIdx[i] === idx ? "bold" : "normal",
borderBottom: "1px solid #333",
userSelect: "none",
}}
tabIndex={-1}
onMouseDown={e => {
e.preventDefault();
handleLoadStack(i, idx);
setOpenDropdownIdx(null);
}}
>
<span style={{
color: dotColor,
fontWeight: "bold",
marginRight: 8,
fontSize: "16px",
verticalAlign: "middle",
}}></span>
Stack {idx + 1} ({stackObj.imageIds.length} images)
</div>
);
})}
</div>
)}
</div>
</div> </div>
</div> </div>
); );
@@ -1328,8 +1811,8 @@ function App() {
<div style={{ <div style={{
position: "relative", position: "relative",
width: "99vw", width: "99vw",
height: "99vh", height: "100%", // <-- changed from 500px
background: "#111", maxHeight: "100%", // <-- ensures it doesn't overflow parent
padding: "12px", // <-- Add this line padding: "12px", // <-- Add this line
boxSizing: "border-box", // <-- Ensure padding doesn't shrink content boxSizing: "border-box", // <-- Ensure padding doesn't shrink content
background: "#222", background: "#222",
@@ -1342,7 +1825,7 @@ function App() {
position: "absolute", position: "absolute",
top: "50%", top: "50%",
transform: "translateY(-50%)", transform: "translateY(-50%)",
left: 20, left: sideMenuOpen ? 250 : -20, // Slide right when open
zIndex: 200, zIndex: 200,
width: 40, width: 40,
height: 40, height: 40,
@@ -1354,7 +1837,7 @@ function App() {
cursor: "pointer", cursor: "pointer",
fontSize: "22px", fontSize: "22px",
boxShadow: "0 2px 8px rgba(0,0,0,0.2)", boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
transition: "background 0.2s", transition: "left 0.25s cubic-bezier(.4,2,.6,1), background 0.2s",
}} }}
onClick={() => setSideMenuOpen(open => !open)} onClick={() => setSideMenuOpen(open => !open)}
aria-label="Open menu" aria-label="Open menu"
@@ -1367,7 +1850,7 @@ function App() {
style={{ style={{
position: "absolute", position: "absolute",
top: 0, top: 0,
left: sideMenuOpen ? 0 : -220, left: sideMenuOpen ? 0 : -260,
width: 220, width: 220,
height: "100%", height: "100%",
background: "#222", background: "#222",
@@ -1377,7 +1860,18 @@ function App() {
transition: "left 0.25s cubic-bezier(.4,2,.6,1)", transition: "left 0.25s cubic-bezier(.4,2,.6,1)",
flexDirection: "column", flexDirection: "column",
padding: "24px 16px 16px 16px", padding: "24px 16px 16px 16px",
display: sideMenuOpen ? "flex" : "none", display: "flex",
overflow: "hidden", // <-- add this
pointerEvents: sideMenuOpen ? "auto" : "none", // <-- add this
}}
>
<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 }}> <div style={{ fontWeight: "bold", fontSize: 18, marginBottom: 24 }}>
@@ -1419,8 +1913,6 @@ function App() {
type="file" type="file"
style={{ display: "none" }} style={{ display: "none" }}
multiple multiple
webkitdirectory="true"
directory="true"
onChange={handleFilesSelected} onChange={handleFilesSelected}
accept=".dcm,application/dicom" accept=".dcm,application/dicom"
/> />
@@ -1523,6 +2015,7 @@ function App() {
</div> </div>
</div> </div>
</div>
{/* Viewport grid menu at top center */} {/* Viewport grid menu at top center */}
@@ -1620,7 +2113,7 @@ function App() {
fontSize: "15px", fontSize: "15px",
}} }}
onClick={() => { onClick={() => {
saveVisibleViewportState(viewportGrid, renderingEngineRef.current); saveVisibleViewportState();
setViewportGrid({ rows: opt.rows, cols: opt.cols }) setViewportGrid({ rows: opt.rows, cols: opt.cols })
setViewportMenuOpen(false) setViewportMenuOpen(false)
}} }}
@@ -1804,8 +2297,24 @@ function App() {
</div> </div>
))} ))}
{/* Expandable advanced ctrl+mouse bindings */} {/* Separate button for advanced Ctrl+Mouse bindings */}
{showAdvancedMouseBindings && ( <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 }}> <div style={{ marginTop: 8 }}>
{ALL_MOUSE_BINDINGS.slice(3).map((btn) => ( {ALL_MOUSE_BINDINGS.slice(3).map((btn) => (
<div key={btn.value + "_ctrl"} style={{ marginBottom: 10 }}> <div key={btn.value + "_ctrl"} style={{ marginBottom: 10 }}>
@@ -1832,6 +2341,7 @@ function App() {
</div> </div>
)} )}
</div> </div>
</div>
<button <button
style={{ style={{
position: "absolute", position: "absolute",
-1
View File
@@ -1,7 +1,6 @@
import { api } from "dicomweb-client" import { api } from "dicomweb-client"
import cornerstoneDICOMImageLoader from "@cornerstonejs/dicom-image-loader" import cornerstoneDICOMImageLoader from "@cornerstonejs/dicom-image-loader"
import { readFile } from "dicom-parser"
/** /**
/** /**
* Uses dicomweb-client to fetch metadata of a study, cache it in cornerstone, * Uses dicomweb-client to fetch metadata of a study, cache it in cornerstone,
@@ -1,7 +1,6 @@
import { api } from "dicomweb-client" import { api } from "dicomweb-client"
import cornerstoneDICOMImageLoader from "@cornerstonejs/dicom-image-loader" import cornerstoneDICOMImageLoader from "@cornerstonejs/dicom-image-loader"
import { readFile } from "dicom-parser"
/** /**
/** /**
* Uses dicomweb-client to fetch metadata of a study, cache it in cornerstone, * Uses dicomweb-client to fetch metadata of a study, cache it in cornerstone,