Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fd3020f0c | ||
|
|
313c7a9e9a | ||
|
|
40be13b51a | ||
|
|
4875b95ab2 | ||
|
|
c7728bf1a2 | ||
|
|
c90c280255 |
+53
-1
@@ -7,7 +7,59 @@
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</html>
|
||||
|
||||
+564
-54
@@ -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 { init as dicomImageLoaderInit } from "@cornerstonejs/dicom-image-loader"
|
||||
import * as cornerstoneTools from '@cornerstonejs/tools';
|
||||
import { StackScrollOutOfBoundsEvent } from 'core/src/types/EventTypes';
|
||||
import { segmentation, Enums as csEnums } from '@cornerstonejs/tools';
|
||||
import { voi } from "@cornerstonejs/tools/utilities"
|
||||
import { readDicomElementExplicit } from "dicom-parser"
|
||||
@@ -18,9 +17,48 @@ import { metaData } from '@cornerstonejs/core';
|
||||
import * as dicomParser from "dicom-parser";
|
||||
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;
|
||||
|
||||
|
||||
type ViewportDiv = HTMLDivElement & {
|
||||
_cornerstoneEnabled?: boolean;
|
||||
_lastViewportType?: any;
|
||||
_resizeObserver?: ResizeObserver;
|
||||
_overlayListener?: EventListener;
|
||||
};
|
||||
|
||||
const { MouseBindings, KeyboardBindings } = csToolsEnums;
|
||||
|
||||
const { IMAGE_RENDERED } = Enums.Events;
|
||||
@@ -88,6 +126,8 @@ function App() {
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const [fullscreenHoverIdx, setFullscreenHoverIdx] = useState<number | null>(null);
|
||||
const [openDropdownIdx, setOpenDropdownIdx] = useState<number | null>(null);
|
||||
// State for overlay info
|
||||
const [imageInfo, setImageInfo] = useState({
|
||||
index: 0,
|
||||
@@ -100,7 +140,10 @@ function App() {
|
||||
const renderingEngineId = "mainRenderingEngine";
|
||||
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 [viewportImageIds, setViewportImageIds] = useState<string[][]>(
|
||||
() => Array(MAX_VIEWPORTS).fill([])
|
||||
@@ -109,20 +152,49 @@ function App() {
|
||||
() => Array(MAX_VIEWPORTS).fill(0)
|
||||
);
|
||||
|
||||
|
||||
// Load available stacks on mount
|
||||
useEffect(() => {
|
||||
availableImageIds().then(stacks => {
|
||||
setAvailableStacks(stacks);
|
||||
// Optionally initialize each viewport with the first stack
|
||||
availableImageIds().then(async stacks => {
|
||||
// Try to extract StudyInstanceUID for each stack
|
||||
const stackEntries: StackEntry[] = await Promise.all(
|
||||
stacks.map(async imageIds => {
|
||||
let studyInstanceUID: string | undefined = undefined;
|
||||
try {
|
||||
// Try to fetch the first image as a blob and parse it
|
||||
const firstId = imageIds[0];
|
||||
if (firstId && firstId.startsWith("wadouri:")) {
|
||||
const url = firstId.slice(8);
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||||
studyInstanceUID = dataSet.string('x0020000d');
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors, fallback to undefined
|
||||
}
|
||||
return { imageIds, studyInstanceUID };
|
||||
})
|
||||
);
|
||||
setAvailableStacks(stackEntries);
|
||||
//setAvailableStacks(stacks.map(imageIds => ({ imageIds, studyInstanceUID: undefined })));
|
||||
setLoadedImageIdsAndCacheMeta(stacks[0]);
|
||||
setViewportImageIds(Array(MAX_VIEWPORTS).fill(stacks[0] || []));
|
||||
});
|
||||
}, []);
|
||||
|
||||
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 => {
|
||||
const next = [...prev];
|
||||
next[viewportIdx] = stack;
|
||||
@@ -142,6 +214,7 @@ function App() {
|
||||
const [referenceLinesEnabled, setReferenceLinesEnabled] = useState(true);
|
||||
|
||||
const [showAdvancedMouseBindings, setShowAdvancedMouseBindings] = useState(false);
|
||||
const [showAdvancedCtrlMouseBindings, setShowAdvancedCtrlMouseBindings] = useState(false);
|
||||
|
||||
const sortViewportImageIdsBySliceLocation = () => {
|
||||
setViewportImageIds(prev => {
|
||||
@@ -194,7 +267,10 @@ function App() {
|
||||
if (url.startsWith("blob:") || url.startsWith("/")) {
|
||||
if (cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager) {
|
||||
// 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") {
|
||||
loadPromises.push(result);
|
||||
}
|
||||
@@ -222,7 +298,7 @@ function App() {
|
||||
const [viewportMenuOpen, setViewportMenuOpen] = useState(false)
|
||||
|
||||
// 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
|
||||
const prevViewportProps = useRef<Record<number, any>>({});
|
||||
@@ -358,6 +434,21 @@ function App() {
|
||||
setMetaModalOpen(true);
|
||||
};
|
||||
|
||||
// Inside your App component:
|
||||
useEffect(() => {
|
||||
window.loadDicomStackFromFiles = (files: FileList | File[]) => {
|
||||
// Convert FileList to array if needed
|
||||
const fileArray = Array.from(files);
|
||||
// Your logic to handle the files and load them as a stack
|
||||
// For example, call your existing handler:
|
||||
handleFilesSelected({ target: { files: fileArray } } as any);
|
||||
};
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
window.loadDicomStackFromFiles = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [altPressed, setAltPressed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -463,23 +554,38 @@ function App() {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// 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;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
// Reset order by slice location when new files are loaded
|
||||
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));
|
||||
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(
|
||||
file => `wadouri:${URL.createObjectURL(file)}`
|
||||
);
|
||||
|
||||
setAvailableStacks(prev => {
|
||||
const next = [...prev, { imageIds, studyInstanceUID }];
|
||||
setViewportImageIds(vpPrev => {
|
||||
const vpNext = [...vpPrev];
|
||||
vpNext[0] = imageIds;
|
||||
return vpNext;
|
||||
});
|
||||
setSelectedStackIdx(selPrev => {
|
||||
const selNext = [...selPrev];
|
||||
selNext[0] = next.length - 1;
|
||||
return selNext;
|
||||
});
|
||||
setLoadedImageIdsAndCacheMeta(imageIds);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -541,6 +647,8 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
const setup = async () => {
|
||||
console.log("setup")
|
||||
|
||||
// 1. Initialize Cornerstone3D and tools ONCE
|
||||
if (!running.current) {
|
||||
running.current = true;
|
||||
@@ -631,7 +739,7 @@ function App() {
|
||||
}
|
||||
|
||||
if (viewportModes[i] === 'stack') {
|
||||
if (imageIds) {
|
||||
if (imageIds && !restoringStateRef.current) {
|
||||
viewport.setStack(imageIds);
|
||||
}
|
||||
// CrosshairsTool is not applicable for stack viewports
|
||||
@@ -666,6 +774,7 @@ function App() {
|
||||
}
|
||||
viewport.render();
|
||||
|
||||
if (!restoringStateRef.current) {
|
||||
// Restore previous scroll position for existing viewports only
|
||||
if (
|
||||
prevScrollIndices.current[i] !== undefined &&
|
||||
@@ -694,21 +803,23 @@ function App() {
|
||||
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) {
|
||||
if ((element)._overlayListener) {
|
||||
// Remove previous listener if present
|
||||
viewport.element.removeEventListener(IMAGE_RENDERED, element._overlayListener);
|
||||
viewport.element.removeEventListener(IMAGE_RENDERED, (element)._overlayListener);
|
||||
}
|
||||
const overlayListener = () => updateOverlay(i, viewport);
|
||||
viewport.element.addEventListener(IMAGE_RENDERED, overlayListener);
|
||||
element._overlayListener = overlayListener;
|
||||
(element)._overlayListener = overlayListener;
|
||||
updateOverlay(i, viewport);
|
||||
}
|
||||
|
||||
@@ -763,9 +874,9 @@ function App() {
|
||||
// Cleanup: disconnect ResizeObservers
|
||||
return () => {
|
||||
viewportRefs.current.forEach(el => {
|
||||
if (el && el._resizeObserver) {
|
||||
el._resizeObserver.disconnect();
|
||||
delete el._resizeObserver;
|
||||
if (el && (el)._resizeObserver) {
|
||||
(el)._resizeObserver.disconnect();
|
||||
delete (el)._resizeObserver;
|
||||
}
|
||||
});
|
||||
cancelAnimationFrame(raf);
|
||||
@@ -780,6 +891,8 @@ function App() {
|
||||
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();
|
||||
}
|
||||
@@ -793,6 +906,7 @@ function App() {
|
||||
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,
|
||||
@@ -853,6 +967,57 @@ function App() {
|
||||
updateActiveReferenceLineViewport(mainToolGroup)
|
||||
}, [activeViewport, referenceLinesEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.setAttribute("webkitdirectory", "true");
|
||||
fileInputRef.current.setAttribute("directory", "true");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// API: Load a stack from a list of wadouri imageIds (strings)
|
||||
window.loadDicomStackFromWadouriList = async (wadouriImageIds: string[]) => {
|
||||
if (!wadouriImageIds || wadouriImageIds.length === 0) return;
|
||||
|
||||
// Try to extract StudyInstanceUID from the first image if possible
|
||||
let studyInstanceUID: string | undefined = undefined;
|
||||
try {
|
||||
const firstId = wadouriImageIds[0];
|
||||
if (firstId && firstId.startsWith("wadouri:")) {
|
||||
const url = firstId.slice(8);
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||||
studyInstanceUID = dataSet.string('x0020000d');
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors, fallback to undefined
|
||||
}
|
||||
|
||||
setAvailableStacks(prev => {
|
||||
const next = [...prev, { imageIds: wadouriImageIds, studyInstanceUID }];
|
||||
setViewportImageIds(vpPrev => {
|
||||
const vpNext = [...vpPrev];
|
||||
vpNext[0] = wadouriImageIds;
|
||||
return vpNext;
|
||||
});
|
||||
setSelectedStackIdx(selPrev => {
|
||||
const selNext = [...selPrev];
|
||||
selNext[0] = next.length - 1;
|
||||
return selNext;
|
||||
});
|
||||
setLoadedImageIdsAndCacheMeta(wadouriImageIds);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.loadDicomStackFromWadouriList = undefined;
|
||||
};
|
||||
}, [setAvailableStacks, setViewportImageIds, setSelectedStackIdx, setLoadedImageIdsAndCacheMeta]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderBySliceLocation) return;
|
||||
|
||||
@@ -906,6 +1071,180 @@ function App() {
|
||||
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 = [];
|
||||
for (let i = 0; i < MAX_VIEWPORTS; i++) {
|
||||
@@ -1047,6 +1386,52 @@ function App() {
|
||||
</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>
|
||||
{/* Overlay info (bottom left of viewport) */}
|
||||
<div
|
||||
style={{
|
||||
@@ -1282,9 +1667,9 @@ function App() {
|
||||
padding: "2px 8px",
|
||||
}}
|
||||
>
|
||||
<select
|
||||
value={selectedStackIdx[i] || 0}
|
||||
onChange={e => handleLoadStack(i, Number(e.target.value))}
|
||||
{/* Custom Stack Dropdown */}
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
style={{
|
||||
background: "#222",
|
||||
color: "#fff",
|
||||
@@ -1293,29 +1678,127 @@ function App() {
|
||||
padding: "2px 8px",
|
||||
fontSize: "13px",
|
||||
marginRight: "6px",
|
||||
}}
|
||||
>
|
||||
{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",
|
||||
minWidth: 120,
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
onClick={() => handleLoadStack(i, selectedStackIdx[i] || 0)}
|
||||
disabled={!availableStacks.length}
|
||||
onClick={() => setOpenDropdownIdx(openDropdownIdx === i ? null : i)}
|
||||
//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>
|
||||
{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>
|
||||
);
|
||||
@@ -1328,8 +1811,8 @@ function App() {
|
||||
<div style={{
|
||||
position: "relative",
|
||||
width: "99vw",
|
||||
height: "99vh",
|
||||
background: "#111",
|
||||
height: "100%", // <-- changed from 500px
|
||||
maxHeight: "100%", // <-- ensures it doesn't overflow parent
|
||||
padding: "12px", // <-- Add this line
|
||||
boxSizing: "border-box", // <-- Ensure padding doesn't shrink content
|
||||
background: "#222",
|
||||
@@ -1342,7 +1825,7 @@ function App() {
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
left: 20,
|
||||
left: sideMenuOpen ? 250 : -20, // Slide right when open
|
||||
zIndex: 200,
|
||||
width: 40,
|
||||
height: 40,
|
||||
@@ -1354,7 +1837,7 @@ function App() {
|
||||
cursor: "pointer",
|
||||
fontSize: "22px",
|
||||
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)}
|
||||
aria-label="Open menu"
|
||||
@@ -1367,7 +1850,7 @@ function App() {
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: sideMenuOpen ? 0 : -220,
|
||||
left: sideMenuOpen ? 0 : -260,
|
||||
width: 220,
|
||||
height: "100%",
|
||||
background: "#222",
|
||||
@@ -1377,7 +1860,18 @@ function App() {
|
||||
transition: "left 0.25s cubic-bezier(.4,2,.6,1)",
|
||||
flexDirection: "column",
|
||||
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 }}>
|
||||
@@ -1419,8 +1913,6 @@ function App() {
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
multiple
|
||||
webkitdirectory="true"
|
||||
directory="true"
|
||||
onChange={handleFilesSelected}
|
||||
accept=".dcm,application/dicom"
|
||||
/>
|
||||
@@ -1523,6 +2015,7 @@ function App() {
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Viewport grid menu at top center */}
|
||||
|
||||
|
||||
@@ -1620,7 +2113,7 @@ function App() {
|
||||
fontSize: "15px",
|
||||
}}
|
||||
onClick={() => {
|
||||
saveVisibleViewportState(viewportGrid, renderingEngineRef.current);
|
||||
saveVisibleViewportState();
|
||||
setViewportGrid({ rows: opt.rows, cols: opt.cols })
|
||||
setViewportMenuOpen(false)
|
||||
}}
|
||||
@@ -1804,8 +2297,24 @@ function App() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Expandable advanced ctrl+mouse bindings */}
|
||||
{showAdvancedMouseBindings && (
|
||||
{/* 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 }}>
|
||||
@@ -1832,6 +2341,7 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { api } from "dicomweb-client"
|
||||
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,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { api } from "dicomweb-client"
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user