basic viewport save and restore fnuctiontality
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
|
||||
<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;
|
||||
@@ -21,6 +22,43 @@ function sendToViewer() {
|
||||
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>
|
||||
|
||||
+312
-32
@@ -24,6 +24,16 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -33,6 +43,12 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
loadDicomStackFromWadouriList?: (wadouriImageIds: string[]) => void;
|
||||
}
|
||||
}
|
||||
|
||||
window.cornerstoneTools = cornerstoneTools;
|
||||
|
||||
|
||||
@@ -110,8 +126,8 @@ function App() {
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const [fullscreenHoverIdx, setFullscreenHoverIdx] = useState<number | null>(null);
|
||||
const [openDropdownIdx, setOpenDropdownIdx] = useState<number | null>(null);
|
||||
const [fullscreenHoverIdx, setFullscreenHoverIdx] = useState<number | null>(null);
|
||||
const [openDropdownIdx, setOpenDropdownIdx] = useState<number | null>(null);
|
||||
// State for overlay info
|
||||
const [imageInfo, setImageInfo] = useState({
|
||||
index: 0,
|
||||
@@ -124,9 +140,10 @@ const [openDropdownIdx, setOpenDropdownIdx] = useState<number | null>(null);
|
||||
const renderingEngineId = "mainRenderingEngine";
|
||||
const renderingEngineRef = useRef<any>(null);
|
||||
|
||||
const restoringStateRef = useRef(false);
|
||||
|
||||
|
||||
const [availableStacks, setAvailableStacks] = useState<StackEntry[]>([]);
|
||||
const [availableStacks, setAvailableStacks] = useState<StackEntry[]>([]);
|
||||
const [activeViewport, setActiveViewport] = useState<number | null>(0);
|
||||
const [viewportImageIds, setViewportImageIds] = useState<string[][]>(
|
||||
() => Array(MAX_VIEWPORTS).fill([])
|
||||
@@ -135,8 +152,9 @@ const [availableStacks, setAvailableStacks] = useState<StackEntry[]>([]);
|
||||
() => Array(MAX_VIEWPORTS).fill(0)
|
||||
);
|
||||
|
||||
|
||||
// Load available stacks on mount
|
||||
useEffect(() => {
|
||||
useEffect(() => {
|
||||
availableImageIds().then(async stacks => {
|
||||
// Try to extract StudyInstanceUID for each stack
|
||||
const stackEntries: StackEntry[] = await Promise.all(
|
||||
@@ -163,7 +181,7 @@ useEffect(() => {
|
||||
setLoadedImageIdsAndCacheMeta(stacks[0]);
|
||||
setViewportImageIds(Array(MAX_VIEWPORTS).fill(stacks[0] || []));
|
||||
});
|
||||
}, []);
|
||||
}, []);
|
||||
|
||||
const handleLoadStack = async (viewportIdx: number, stackIdx: number) => {
|
||||
const stackObj = availableStacks[stackIdx];
|
||||
@@ -536,7 +554,7 @@ useEffect(() => {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Handler for file input change
|
||||
const handleFilesSelected = async (event: React.ChangeEvent<HTMLInputElement> | { target: { files: File[] | FileList } }) => {
|
||||
const handleFilesSelected = async (event: React.ChangeEvent<HTMLInputElement> | { target: { files: File[] | FileList } }) => {
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
@@ -544,8 +562,8 @@ const handleFilesSelected = async (event: React.ChangeEvent<HTMLInputElement> |
|
||||
|
||||
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));
|
||||
const arrayBuffer = await fileArray[0].arrayBuffer();
|
||||
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||||
studyInstanceUID = dataSet.string('x0020000d');
|
||||
console.log("StudyInstanceUID:", studyInstanceUID);
|
||||
|
||||
@@ -568,7 +586,7 @@ const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||||
setLoadedImageIdsAndCacheMeta(imageIds);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const handleToolChange = (mouseButton: string, toolName: string) => {
|
||||
@@ -629,6 +647,8 @@ const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||||
|
||||
useEffect(() => {
|
||||
const setup = async () => {
|
||||
console.log("setup")
|
||||
|
||||
// 1. Initialize Cornerstone3D and tools ONCE
|
||||
if (!running.current) {
|
||||
running.current = true;
|
||||
@@ -719,7 +739,7 @@ const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||||
}
|
||||
|
||||
if (viewportModes[i] === 'stack') {
|
||||
if (imageIds) {
|
||||
if (imageIds && !restoringStateRef.current) {
|
||||
viewport.setStack(imageIds);
|
||||
}
|
||||
// CrosshairsTool is not applicable for stack viewports
|
||||
@@ -754,6 +774,7 @@ const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||||
}
|
||||
viewport.render();
|
||||
|
||||
if (!restoringStateRef.current) {
|
||||
// Restore previous scroll position for existing viewports only
|
||||
if (
|
||||
prevScrollIndices.current[i] !== undefined &&
|
||||
@@ -782,11 +803,13 @@ const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||||
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
|
||||
@@ -868,6 +891,8 @@ const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||||
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();
|
||||
}
|
||||
@@ -881,6 +906,7 @@ const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||||
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,
|
||||
@@ -948,6 +974,50 @@ const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
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;
|
||||
|
||||
@@ -1001,11 +1071,187 @@ const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||||
mainToolGroup.setToolDisabled(ReferenceLinesTool.toolName)
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
// Export the current viewer state as JSON
|
||||
window.exportViewerState = () => {
|
||||
const numActive = viewportGrid.rows * viewportGrid.cols;
|
||||
const activeViewportModes = viewportModes.slice(0, numActive);
|
||||
const activeSelectedStackIdx = selectedStackIdx.slice(0, numActive);
|
||||
const activeAvailableStacks = availableStacks;
|
||||
const activeViewportImageIds = viewportImageIds.slice(0, numActive);
|
||||
|
||||
// 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;
|
||||
// Collect viewport properties, stack positions, and camera for active viewports
|
||||
const renderingEngine = renderingEngineRef.current;
|
||||
const viewportProperties: any[] = [];
|
||||
const stackPositions: number[] = [];
|
||||
const cameraProperties: any[] = [];
|
||||
for (let i = 0; i < numActive; i++) {
|
||||
let props = null;
|
||||
let stackPos = null;
|
||||
let camera = null;
|
||||
if (renderingEngine) {
|
||||
const viewportId = `CT_${i}`;
|
||||
const viewport = renderingEngine.getViewport(viewportId);
|
||||
if (viewport) {
|
||||
if (typeof viewport.getProperties === "function") {
|
||||
props = viewport.getProperties();
|
||||
}
|
||||
if (typeof viewport.getCurrentImageIdIndex === "function") {
|
||||
stackPos = viewport.getCurrentImageIdIndex();
|
||||
}
|
||||
if (typeof viewport.getCamera === "function") {
|
||||
camera = viewport.getCamera();
|
||||
}
|
||||
}
|
||||
}
|
||||
viewportProperties.push(props);
|
||||
stackPositions.push(stackPos);
|
||||
cameraProperties.push(camera);
|
||||
}
|
||||
|
||||
const state = {
|
||||
viewportGrid,
|
||||
viewportModes: activeViewportModes,
|
||||
selectedStackIdx: activeSelectedStackIdx,
|
||||
availableStacks: activeAvailableStacks,
|
||||
viewportImageIds: activeViewportImageIds,
|
||||
viewportProperties,
|
||||
stackPositions,
|
||||
cameraProperties,
|
||||
};
|
||||
return JSON.stringify(state, null, 2);
|
||||
};
|
||||
|
||||
window.importViewerState = (json: string) => {
|
||||
try {
|
||||
console.log("Importing viewer state:", json);
|
||||
restoringStateRef.current = true;
|
||||
|
||||
const state = typeof json === "string" ? JSON.parse(json) : json;
|
||||
if (state.viewportGrid) setViewportGrid(state.viewportGrid);
|
||||
if (state.viewportModes) setViewportModes(state.viewportModes);
|
||||
if (state.selectedStackIdx) setSelectedStackIdx(state.selectedStackIdx);
|
||||
if (state.availableStacks) setAvailableStacks(state.availableStacks);
|
||||
if (state.viewportImageIds) setViewportImageIds(state.viewportImageIds);
|
||||
|
||||
setTimeout(() => {
|
||||
const renderingEngine = renderingEngineRef.current;
|
||||
if (!renderingEngine) {
|
||||
restoringStateRef.current = false;
|
||||
return;
|
||||
}
|
||||
const numActive = state.viewportGrid?.rows * state.viewportGrid?.cols || 1;
|
||||
for (let i = 0; i < numActive; i++) {
|
||||
const viewportId = `CT_${i}`;
|
||||
const viewport = renderingEngine.getViewport(viewportId);
|
||||
if (!viewport) continue;
|
||||
|
||||
// Restore stack position (scroll)
|
||||
if (
|
||||
state.stackPositions &&
|
||||
typeof state.stackPositions[i] === "number" &&
|
||||
typeof viewport.setImageIdIndex === "function"
|
||||
) {
|
||||
if (csUtilities && csUtilities.jumpToSlice) {
|
||||
csUtilities.jumpToSlice(viewport.element, { imageIndex: state.stackPositions[i] });
|
||||
} else {
|
||||
viewport.setImageIdIndex(state.stackPositions[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore viewport properties (VOI, invert, etc.)
|
||||
if (
|
||||
state.viewportProperties &&
|
||||
state.viewportProperties[i] &&
|
||||
typeof viewport.setProperties === "function"
|
||||
) {
|
||||
const props = state.viewportProperties[i];
|
||||
const { voiRange, invert, interpolationType, VOILUTFunction, colormap } = props;
|
||||
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;
|
||||
}
|
||||
viewport.setProperties(propsToRestore);
|
||||
viewport.render();
|
||||
}
|
||||
|
||||
// Restore camera properties (zoom, pan, orientation)
|
||||
if (
|
||||
state.cameraProperties &&
|
||||
state.cameraProperties[i] &&
|
||||
typeof viewport.setCamera === "function"
|
||||
) {
|
||||
viewport.setCamera(state.cameraProperties[i]);
|
||||
viewport.render();
|
||||
}
|
||||
}
|
||||
restoringStateRef.current = false;
|
||||
console.log("Viewer state restored successfully");
|
||||
}, 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++) {
|
||||
@@ -1148,7 +1394,7 @@ const referenceUID = referenceStackObj?.studyInstanceUID;
|
||||
</div>
|
||||
)}
|
||||
{/* Fullscreen button below the Volume/Stack toggle */}
|
||||
<button
|
||||
<button
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 40,
|
||||
@@ -1183,7 +1429,7 @@ const referenceUID = referenceStackObj?.studyInstanceUID;
|
||||
}}
|
||||
onMouseEnter={() => setFullscreenHoverIdx(i)}
|
||||
onMouseLeave={() => setFullscreenHoverIdx(null)}
|
||||
>
|
||||
>
|
||||
<span style={{ fontSize: "18px", lineHeight: 1 }}>⛶</span>
|
||||
{fullscreenHoverIdx === i && (
|
||||
<span style={{ marginLeft: 6 }}>
|
||||
@@ -1192,7 +1438,7 @@ const referenceUID = referenceStackObj?.studyInstanceUID;
|
||||
: "Fullscreen"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</button>
|
||||
{/* Overlay info (bottom left of viewport) */}
|
||||
<div
|
||||
style={{
|
||||
@@ -1412,7 +1658,7 @@ const referenceUID = referenceStackObj?.studyInstanceUID;
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 8,
|
||||
@@ -1427,7 +1673,7 @@ const referenceUID = referenceStackObj?.studyInstanceUID;
|
||||
borderRadius: "4px",
|
||||
padding: "2px 8px",
|
||||
}}
|
||||
>
|
||||
>
|
||||
{/* Custom Stack Dropdown */}
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
@@ -1444,7 +1690,7 @@ const referenceUID = referenceStackObj?.studyInstanceUID;
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => setOpenDropdownIdx(openDropdownIdx === i ? null : i)}
|
||||
onBlur={() => setTimeout(() => setOpenDropdownIdx(null), 150)}
|
||||
//onBlur={() => setTimeout(() => setOpenDropdownIdx(null), 150)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={openDropdownIdx === i}
|
||||
>
|
||||
@@ -1469,12 +1715,16 @@ const referenceUID = referenceStackObj?.studyInstanceUID;
|
||||
})()}
|
||||
<span style={{ float: "right", fontSize: "12px" }}>▼</span>
|
||||
</button>
|
||||
{openDropdownIdx === i && (
|
||||
{openDropdownIdx === i && (
|
||||
<div
|
||||
ref={el => {
|
||||
if (el) el.focus();
|
||||
}}
|
||||
tabIndex={0}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
bottom: "110%", // <-- changed from top: "110%" to bottom: "110%"
|
||||
bottom: "110%",
|
||||
background: "#222",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
@@ -1483,9 +1733,37 @@ const referenceUID = referenceStackObj?.studyInstanceUID;
|
||||
minWidth: 180,
|
||||
maxHeight: 220,
|
||||
overflowY: "auto",
|
||||
overscrollBehavior: "contain",
|
||||
scrollbarWidth: "thin",
|
||||
}}
|
||||
tabIndex={-1}
|
||||
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;
|
||||
@@ -1505,7 +1783,9 @@ const referenceUID = referenceStackObj?.studyInstanceUID;
|
||||
alignItems: "center",
|
||||
fontWeight: selectedStackIdx[i] === idx ? "bold" : "normal",
|
||||
borderBottom: "1px solid #333",
|
||||
userSelect: "none",
|
||||
}}
|
||||
tabIndex={-1}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
handleLoadStack(i, idx);
|
||||
@@ -1524,9 +1804,9 @@ const referenceUID = referenceStackObj?.studyInstanceUID;
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1547,7 +1827,7 @@ const referenceUID = referenceStackObj?.studyInstanceUID;
|
||||
}}>
|
||||
<div>
|
||||
{/* Side menu toggle button */}
|
||||
<button
|
||||
<button
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
@@ -1568,12 +1848,12 @@ const referenceUID = referenceStackObj?.studyInstanceUID;
|
||||
}}
|
||||
onClick={() => setSideMenuOpen(open => !open)}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
</button>
|
||||
|
||||
{/* Side menu drawer */}
|
||||
<div
|
||||
{/* Side menu drawer */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
@@ -1591,7 +1871,7 @@ const referenceUID = referenceStackObj?.studyInstanceUID;
|
||||
overflow: "hidden", // <-- add this
|
||||
pointerEvents: sideMenuOpen ? "auto" : "none", // <-- add this
|
||||
}}
|
||||
>
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -1740,7 +2020,7 @@ const referenceUID = referenceStackObj?.studyInstanceUID;
|
||||
</button>
|
||||
{/* Add more menu items here if needed */}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Viewport grid menu at top center */}
|
||||
|
||||
Reference in New Issue
Block a user