add a few new features

This commit is contained in:
Ross
2025-06-23 09:53:09 +01:00
parent 11d1dc4339
commit ce13f9cad2
2 changed files with 297 additions and 78 deletions
+295 -76
View File
@@ -169,9 +169,26 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const [availableStacks, setAvailableStacks] = useState<StackEntry[]>([]);
const [activeViewport, setActiveViewport] = useState<number | null>(0);
const [viewportImageIds, setViewportImageIds] = useState<string[][]>(
// 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)
);
@@ -333,7 +350,6 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
if (autoCacheStack) {
for (const imageId of imageIds) {
if (imageId.startsWith("wadouri:")) {
console.log("Caching metadata for imageId:", imageId);
try {
const url = imageId.slice(8);
if (cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager) {
@@ -442,6 +458,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
}
}, [])
const [metaModalOpen, setMetaModalOpen] = useState(false);
const [metaModalContent, setMetaModalContent] = useState<any>(null);
@@ -866,7 +884,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
}
if (viewportModes[i] === 'stack') {
if (imageIds && !restoringStateRef.current) {
//if (imageIds && !restoringStateRef.current) {
if (imageIds && imageIds.length > 0) {
viewport.setStack(imageIds);
}
// CrosshairsTool is not applicable for stack viewports
@@ -1078,24 +1097,24 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
return () => document.removeEventListener("mousedown", handleClick);
}, [presetsOpen]);
useEffect(() => {
const preventExtraButtonDefault = (e: MouseEvent) => {
// 4th button: button === 3, 5th button: button === 4
if (e.button === 3 || e.button === 4) {
e.preventDefault();
//e.stopPropagation();
return false;
}
};
window.addEventListener("mousedown", preventExtraButtonDefault, true);
window.addEventListener("mouseup", preventExtraButtonDefault, true);
window.addEventListener("auxclick", preventExtraButtonDefault, true);
return () => {
window.removeEventListener("mousedown", preventExtraButtonDefault, true);
window.removeEventListener("mouseup", preventExtraButtonDefault, true);
window.removeEventListener("auxclick", preventExtraButtonDefault, true);
};
}, []);
useEffect(() => {
const 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 handleContextMenu = (e: MouseEvent) => {
@@ -1164,47 +1183,47 @@ useEffect(() => {
};
}, [setAvailableStacks, setViewportImageIds, setSelectedStackIdx, setLoadedImageIdsAndCacheMeta]);
useEffect(() => {
if (!orderBySliceLocation) return;
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;
// 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;
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 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;
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 a.imageId.localeCompare(b.imageId);
return imageIds;
});
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;
});
if (modified) setStackOrderModified(true);
return next;
});
}, [orderBySliceLocation, availableStacks]);
}, [orderBySliceLocation, availableStacks]);
function updateActiveReferenceLineViewport(mainToolGroup: cornerstoneTools.Types.IToolGroup) {
// Ensure the main tool group is defined (it won't be during setup)
@@ -1248,7 +1267,7 @@ useEffect(() => {
viewportGrid.cols !== 1 ||
availableStacks.length !== 1 ||
!viewportImageIds[0] ||
viewportImageIds[0].length === 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).");
@@ -1320,6 +1339,33 @@ useEffect(() => {
window[`truncateStack_${apiKey}`] = truncateStack;
window[`getCurrentStackPosition_${apiKey}`] = getCurrentStackPosition;
window[`loadAdditionalStack_${apiKey}`] = async (imageIds: string[], studyInstanceUID?: string) => {
if (!Array.isArray(imageIds) || imageIds.length === 0) return;
// Optionally extract StudyInstanceUID if not provided
let uid = studyInstanceUID;
if (!uid && imageIds[0]?.startsWith("wadouri:")) {
try {
const url = imageIds[0].slice(8);
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
uid = dataSet.string('x0020000d');
} catch (e) {
// Ignore errors, fallback to undefined
}
}
setAvailableStacks(prev => [...prev, { imageIds, studyInstanceUID: uid }]);
// Optionally, auto-populate a viewport with the new stack if there is an empty viewport
setViewportImageIds(prev => {
const next = [...prev];
const emptyIdx = next.findIndex(ids => !ids || ids.length === 0);
if (emptyIdx !== -1) {
next[emptyIdx] = imageIds;
}
return next;
});
setLoadedImageIdsAndCacheMeta(imageIds);
};
// Export the current viewer state as JSON
window[`exportViewerState_${apiKey}`] = () => {
@@ -1372,7 +1418,6 @@ useEffect(() => {
return JSON.stringify(state);
};
// Import and restore a viewer state from JSON (using stack indices)
window[`importViewerState_${apiKey}`] = (json: string) => {
try {
restoringStateRef.current = true;
@@ -1391,38 +1436,101 @@ useEffect(() => {
setSelectedStackIdx(state.stackIdx);
}
setTimeout(() => {
// --- Delay the start of tryRestore until after the next paint ---
const maxAttempts = 30;
let attempts = 0;
async function tryRestore() {
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;
let allReady = true;
for (let i = 0; i < numActive; 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;
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;
}
}
if (!allReady && attempts < maxAttempts) {
attempts++;
setTimeout(() => { tryRestore(); }, 100);
return;
}
// 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]);
console.log("HELLO");
// Now safe to restore state for ALL viewports
for (let i = 0; i < numActive; i++) {
console.log("Restoring state for viewport", i);
const viewport = renderingEngine.getViewport(`CT_${i}`);
if (!viewport) continue;
const stackIdx = state.stackIdx?.[i];
const stackObj = state.stacks?.[stackIdx];
const importedImageIds = stackObj?.i || [];
if (!importedImageIds.length) continue;
console.log("mode", state.modes?.[i]);
if (state.modes?.[i] === "volume") {
// --- Restore volume ---
console.log("Restoring volume for viewport", i, importedImageIds);
const volumeId = `myVolumeId_${i}`;
const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: importedImageIds });
volume.load();
viewport.setVolumes([{ volumeId }]);
viewport.render();
} else {
// --- Restore stack ---
if (viewport.setStack) {
viewport.setStack(importedImageIds);
viewport.render();
}
// Restore stack position
if (
state.stackPos &&
typeof state.stackPos[i] === "number" &&
typeof viewport.setImageIdIndex === "function"
) {
if (csUtilities && csUtilities.jumpToSlice) {
csUtilities.jumpToSlice(viewport.element, { imageIndex: state.stackPos[i] });
} else {
viewport.setImageIdIndex(state.stackPos[i]);
}
}
}
// Restore properties
// Restore properties (defensive colormap check)
if (
state.props &&
state.props[i] &&
typeof viewport.setProperties === "function"
) {
viewport.setProperties(state.props[i]);
const propsToRestore = { ...state.props[i] };
if (
propsToRestore.colormap !== undefined &&
propsToRestore.colormap !== null &&
typeof propsToRestore.colormap !== "string" &&
(typeof propsToRestore.colormap !== "object" || !("name" in propsToRestore.colormap))
) {
delete propsToRestore.colormap;
}
viewport.setProperties(propsToRestore);
viewport.render();
}
@@ -1437,13 +1545,16 @@ useEffect(() => {
}
}
restoringStateRef.current = false;
}, 300);
}
// Delay start until after next paint
setTimeout(() => { tryRestore(); }, 0);
} catch (e) {
restoringStateRef.current = false;
alert("Failed to import viewer state: " + e);
}
};
// Export all annotations using cornerstoneTools.annotation.state.getAllAnnotations()
window[`exportAnnotations_${apiKey}`] = () => {
try {
@@ -1669,6 +1780,7 @@ useEffect(() => {
window[`resetViewer_${apiKey}`] = undefined;
window[`truncateStack_${apiKey}`] = undefined;
window[`getCurrentStackPosition_${apiKey}`] = undefined;
window[`loadAdditionalStack_${apiKey}`] = undefined;
};
}, [
viewportGrid,
@@ -1681,9 +1793,77 @@ useEffect(() => {
setSelectedStackIdx,
setAvailableStacks,
setViewportImageIds,
setAvailableStacks,
setViewportImageIds,
setLoadedImageIdsAndCacheMeta,
]);
useEffect(() => {
function handleArrowKeyNavigation(e: KeyboardEvent) {
if (activeViewport === null) return;
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) return;
const viewport = renderingEngine.getViewport(`CT_${activeViewport}`);
if (!viewport) return;
// --- 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;
let idx = viewport.getCurrentImageIdIndex();
if (e.key === "ArrowUp" || e.key === "ArrowRight") {
idx = idx < imageIds.length - 1 ? idx + 1 : 0;
csUtilities.jumpToSlice(viewport.element, { imageIndex: idx });
updateOverlay(activeViewport, viewport);
e.preventDefault();
} else if (e.key === "ArrowDown" || e.key === "ArrowLeft") {
idx = idx > 0 ? idx - 1 : imageIds.length - 1;
csUtilities.jumpToSlice(viewport.element, { imageIndex: idx });
updateOverlay(activeViewport, viewport);
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.setSliceIndex === "function" &&
typeof viewport.getNumberOfSlices === "function"
) {
const numSlices = viewport.getNumberOfSlices();
let idx = viewport.getSliceIndex();
if (numSlices > 1) {
if (e.key === "ArrowUp" || e.key === "ArrowRight") {
idx = idx < numSlices - 1 ? idx + 1 : 0;
viewport.setSliceIndex(idx);
viewport.render();
updateOverlay(activeViewport, viewport);
e.preventDefault();
} else if (e.key === "ArrowDown" || e.key === "ArrowLeft") {
idx = idx > 0 ? idx - 1 : numSlices - 1;
viewport.setSliceIndex(idx);
viewport.render();
updateOverlay(activeViewport, viewport);
e.preventDefault();
}
}
}
}
}
window.addEventListener("keydown", handleArrowKeyNavigation);
return () => window.removeEventListener("keydown", handleArrowKeyNavigation);
}, [activeViewport, viewportModes, viewportImageIds, updateOverlay]);
// Reference stack and UID for study comparison (use active viewport)
const referenceStackIdx = activeViewport !== null ? selectedStackIdx[activeViewport] : 0;
@@ -2719,6 +2899,45 @@ useEffect(() => {
onFocus={() => setGridBtnActive(true)}
tabIndex={-1}
/>
{/* --- Fullscreen button next to grid button --- */}
<button
style={{
position: "relative",
top: gridBtnActive ? "0" : "-22px",
opacity: gridBtnActive ? 1 : 0.5,
transition: "top 0.25s cubic-bezier(.4,2,.6,1), opacity 0.2s",
padding: "6px 16px",
background: "#222",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginLeft: "4px",
marginRight: "8px",
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
pointerEvents: gridBtnActive ? "auto" : "none",
zIndex: 2,
display: gridBtnActive ? "block" : "none",
}}
title="Toggle Fullscreen"
onClick={() => {
const root = document.querySelector(".dicom-viewer-root") as HTMLElement;
if (!root) return;
if (document.fullscreenElement === root) {
document.exitFullscreen();
} else if (root.requestFullscreen) {
root.requestFullscreen();
} else if ((root as any).webkitRequestFullscreen) {
(root as any).webkitRequestFullscreen();
} else if ((root as any).msRequestFullscreen) {
(root as any).msRequestFullscreen();
}
}}
>
Fullscreen
</button>
<button
className="grid-menu-btn"
style={{