diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index fbf2d4e..7cb38a7 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -28,7 +28,7 @@ declare global { cornerstone3dTools: typeof cornerstoneTools; cornerstone3d: typeof cornerstone3d; cornerstoneDICOMImageLoader: typeof cornerstoneDICOMImageLoader; - + } } function pixelToWorld(imageId: string, point: { x: number, y: number }) { @@ -257,63 +257,63 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer }, [annotationJson, viewerState, container_id]); // Load available stacks on mount -useEffect(() => { - let mounted = true; + useEffect(() => { + let mounted = true; - imageStacks() - .then(async (descriptors) => { - if (!mounted) return; - const stacksRaw = Array.isArray(descriptors) ? descriptors : []; - const normalized: Array = await Promise.all( - stacksRaw.map(async (d) => { - if (Array.isArray(d)) { - return { imageIds: d as string[] }; - } - const imageIds = Array.isArray((d as any).imageIds) ? (d as any).imageIds : (Array.isArray((d as any).i) ? (d as any).i : []); - // try to preserve old studyInstanceUID extraction if needed (best-effort) - let studyInstanceUID: string | undefined = (d as any).studyInstanceUID; - // optional: attempt to fetch first file and extract StudyInstanceUID if dicomParser available - if (!studyInstanceUID && imageIds.length > 0) { - try { - const first = imageIds[0]; - if (typeof fetch === 'function' && typeof (window as any).dicomParser !== 'undefined' && first.startsWith('wadouri:')) { - const url = first.slice(7); - const resp = await fetch(url); - const buffer = await resp.arrayBuffer(); - const ds = (window as any).dicomParser.parseDicom(new Uint8Array(buffer)); - studyInstanceUID = ds.string('x0020000d'); - } - } catch (e) { - // ignore UID extraction failures + imageStacks() + .then(async (descriptors) => { + if (!mounted) return; + const stacksRaw = Array.isArray(descriptors) ? descriptors : []; + const normalized: Array = await Promise.all( + stacksRaw.map(async (d) => { + if (Array.isArray(d)) { + return { imageIds: d as string[] }; } - } - return { - imageIds, - studyInstanceUID, - name: (d as any).name, - caseId: (d as any).caseId, - }; - }) - ); + const imageIds = Array.isArray((d as any).imageIds) ? (d as any).imageIds : (Array.isArray((d as any).i) ? (d as any).i : []); + // try to preserve old studyInstanceUID extraction if needed (best-effort) + let studyInstanceUID: string | undefined = (d as any).studyInstanceUID; + // optional: attempt to fetch first file and extract StudyInstanceUID if dicomParser available + if (!studyInstanceUID && imageIds.length > 0) { + try { + const first = imageIds[0]; + if (typeof fetch === 'function' && typeof (window as any).dicomParser !== 'undefined' && first.startsWith('wadouri:')) { + const url = first.slice(7); + const resp = await fetch(url); + const buffer = await resp.arrayBuffer(); + const ds = (window as any).dicomParser.parseDicom(new Uint8Array(buffer)); + studyInstanceUID = ds.string('x0020000d'); + } + } catch (e) { + // ignore UID extraction failures + } + } + return { + imageIds, + studyInstanceUID, + name: (d as any).name, + caseId: (d as any).caseId, + }; + }) + ); - setAvailableStacks(normalized); + setAvailableStacks(normalized); - // Seed viewports with first stack if present (preserve old behavior) - if (normalized[0]) { - await setLoadedImageIdsAndCacheMeta(normalized[0].imageIds); - setViewportImageIds(Array(MAX_VIEWPORTS).fill(normalized[0].imageIds || [])); - } else { + // Seed viewports with first stack if present (preserve old behavior) + if (normalized[0]) { + await setLoadedImageIdsAndCacheMeta(normalized[0].imageIds); + setViewportImageIds(Array(MAX_VIEWPORTS).fill(normalized[0].imageIds || [])); + } else { + setViewportImageIds(Array(MAX_VIEWPORTS).fill([])); + } + }) + .catch(err => { + console.error("Failed to load imageStacks:", err); + setAvailableStacks([]); setViewportImageIds(Array(MAX_VIEWPORTS).fill([])); - } - }) - .catch(err => { - console.error("Failed to load imageStacks:", err); - setAvailableStacks([]); - setViewportImageIds(Array(MAX_VIEWPORTS).fill([])); - }); + }); - return () => { mounted = false; }; -}, []); + return () => { mounted = false; }; + }, []); const handleLoadStack = async (viewportIdx: number, stackIdx: number) => { const stackObj = availableStacks[stackIdx]; @@ -613,30 +613,30 @@ useEffect(() => { }; -// --- Load from localStorage on mount --- -useEffect(() => { - try { - const mouse = localStorage.getItem(MOUSE_SETTINGS_KEY); - const ctrl = localStorage.getItem(CTRL_MOUSE_SETTINGS_KEY); - if (mouse) setMouseToolBindings(JSON.parse(mouse)); - if (ctrl) setCtrlMouseToolBindings(JSON.parse(ctrl)); - } catch (e) { - // Ignore parse errors - } -}, []); + // --- Load from localStorage on mount --- + useEffect(() => { + try { + const mouse = localStorage.getItem(MOUSE_SETTINGS_KEY); + const ctrl = localStorage.getItem(CTRL_MOUSE_SETTINGS_KEY); + if (mouse) setMouseToolBindings(JSON.parse(mouse)); + if (ctrl) setCtrlMouseToolBindings(JSON.parse(ctrl)); + } catch (e) { + // Ignore parse errors + } + }, []); -// --- Save to localStorage when changed --- -useEffect(() => { - try { - localStorage.setItem(MOUSE_SETTINGS_KEY, JSON.stringify(mouseToolBindings)); - } catch (e) {} -}, [mouseToolBindings]); + // --- Save to localStorage when changed --- + useEffect(() => { + try { + localStorage.setItem(MOUSE_SETTINGS_KEY, JSON.stringify(mouseToolBindings)); + } catch (e) { } + }, [mouseToolBindings]); -useEffect(() => { - try { - localStorage.setItem(CTRL_MOUSE_SETTINGS_KEY, JSON.stringify(ctrlMouseToolBindings)); - } catch (e) {} -}, [ctrlMouseToolBindings]); + useEffect(() => { + try { + localStorage.setItem(CTRL_MOUSE_SETTINGS_KEY, JSON.stringify(ctrlMouseToolBindings)); + } catch (e) { } + }, [ctrlMouseToolBindings]); useEffect(() => { window.loadDicomStackFromFiles = (files: FileList | File[]) => { @@ -2880,761 +2880,766 @@ useEffect(() => { }} > {availableStacks.map((stackObj, idx) => { - const thisUID = stackObj.studyInstanceUID; - const sameStudy = referenceUID && thisUID && referenceUID === thisUID; - const dotColor = sameStudy ? "#4caf50" : "#888"; - const isEmpty = stackObj.imageIds.length === 0; + const imageIds = (stackObj && Array.isArray((stackObj as any).imageIds)) ? (stackObj as any).imageIds : []; + const label = (stackObj && ((stackObj as any).name || (stackObj as any).caseId)) + ? ((stackObj as any).name || `Case: ${(stackObj as any).caseId}`) + : `Stack ${idx + 1}`; + const count = imageIds.length; + const invalid = !Array.isArray(imageIds) || count === 0; + return ( -
{ - e.preventDefault(); - if (!isEmpty) { - handleLoadStack(i, idx); - setOpenDropdownIdx(null); + onClick={(e) => { + e.stopPropagation(); + if (invalid) { + console.warn(`Attempted to select invalid stack at index ${idx}`, stackObj); + return; } + // Select the stack for this viewport + // Use the shared handler so selection behavior is consistent + handleLoadStack(i, idx).catch((err) => { + console.error("Failed to load stack:", err); + }); + + // Close the dropdown + setOpenDropdownIdx(null); }} > - - Stack {idx + 1} ({stackObj.imageIds.length} images - {isEmpty && (empty)}) -
+
{label}
+
{count} image{count === 1 ? '' : 's'}
+ ); })} )} - - )} - - ); + + ) } + + ); +} - return ( - // App root -
- {/* Loading overlay */} - {loadingState && ( +return ( + // App root +
+ {/* Loading overlay */} + {loadingState && ( +
+ {loadingState === "annotations" + ? "Loading Annotations..." + : "Loading Viewer State..."} +
+ )} + +
+ {/* Side menu toggle button */} + + + {/* Side menu drawer */} + {sideMenuOpen && (
- {loadingState === "annotations" - ? "Loading Annotations..." - : "Loading Viewer State..."} -
- )} - -
- {/* Side menu toggle button */} - - - {/* Side menu drawer */} - {sideMenuOpen && (
+
+ Menu + +
+ +
-
- Menu - -
- setOrderBySliceLocation(e.target.checked)} + style={{ accentColor: "#666" }} /> -
- setOrderBySliceLocation(e.target.checked)} - style={{ accentColor: "#666" }} - /> - -
- - - - - - - {/* Add more menu items here if needed */} - +
+ + + + + + + {/* Add more menu items here if needed */} +
- )} -
- {/* Viewport grid menu at top center */} +
+ )} +
+ {/* Viewport grid menu at top center */} +
+ {/* Transparent hit area to catch hover/focus */}
- {/* Transparent hit area to catch hover/focus */} -
setGridBtnActive(true)} - onFocus={() => setGridBtnActive(true)} - tabIndex={-1} - /> - {/* --- Fullscreen button next to grid button --- */} - - - {viewportMenuOpen && ( -
- {gridOptions.map(opt => ( - - ))} -
- )} -
- - - {/* Viewport grid (inside main div) */} -
- {viewports} - - -
- - - {/* Settings button at top right */} + onMouseEnter={() => setGridBtnActive(true)} + onFocus={() => setGridBtnActive(true)} + tabIndex={-1} + /> + {/* --- Fullscreen button next to grid button --- */} + + {viewportMenuOpen && ( +
+ {gridOptions.map(opt => ( + + ))} +
+ )} +
- {/* Settings Modal (viewport-local, not fixed to page) */} - { - settingsOpen && ( + + {/* Viewport grid (inside main div) */} +
+ {viewports} + + +
+ + + {/* Settings button at top right */} + + + {/* Settings Modal (viewport-local, not fixed to page) */} + { + settingsOpen && ( +
setSettingsOpen(false)} + >
setSettingsOpen(false)} + onClick={e => e.stopPropagation()} > -
e.stopPropagation()} - > -
- Settings -
- {/* Settings content: Tool dropdowns */} -
-
Mouse Button Tool Bindings
- {MOUSE_BUTTONS.map((btn) => ( -
- - -
- ))} - - {/* Expandable advanced mouse bindings */} -
-
+ {/* Settings content: Tool dropdowns */} +
+
Mouse Button Tool Bindings
+ {MOUSE_BUTTONS.map((btn) => ( +
+ + setMouseToolBindings(prev => ({ ...prev, [btn.value]: e.target.value }))} - style={{ - background: "#333", - color: "#fff", - border: "1px solid #444", - borderRadius: "4px", - padding: "4px 8px", - fontSize: "14px", - }} - > - - {TOOL_OPTIONS.map(opt => ( - - ))} - -
- ))} -
- )} + {TOOL_OPTIONS.map(opt => ( + + ))} +
- {/* Ctrl + Mouse Button Tool Bindings */} -
Ctrl + Mouse Button Tool Bindings
- {MOUSE_BUTTONS.map((btn) => ( -
- - -
- ))} + ))} - {/* Separate button for advanced Ctrl+Mouse bindings */} -
- + {showAdvancedMouseBindings && ( +
+ {ALL_MOUSE_BINDINGS.slice(3).map((btn) => ( +
+ + +
+ ))} +
+ )} +
+ {/* Ctrl + Mouse Button Tool Bindings */} +
Ctrl + Mouse Button Tool Bindings
+ {MOUSE_BUTTONS.map((btn) => ( +
+ + setCtrlMouseToolBindings(prev => ({ ...prev, [btn.value]: e.target.value }))} - style={{ - background: "#333", - color: "#fff", - border: "1px solid #444", - borderRadius: "4px", - padding: "4px 8px", - fontSize: "14px", - }} - > - - {TOOL_OPTIONS.map(opt => ( - - ))} - -
- ))} -
- )} + + {TOOL_OPTIONS.map(opt => ( + + ))} +
+ ))} + + {/* Separate button for advanced Ctrl+Mouse bindings */} +
+ + {showAdvancedCtrlMouseBindings && ( +
+ {ALL_MOUSE_BINDINGS.slice(3).map((btn) => ( +
+ + +
+ ))} +
+ )}
-
+
- ) - } - { - metaModalOpen && ( +
+ ) + } + { + metaModalOpen && ( +
setMetaModalOpen(false)} + >
setMetaModalOpen(false)} + onClick={e => e.stopPropagation()} > -
e.stopPropagation()} - > -
- DICOM Metadata -
- {metaModalContent} - +
+ DICOM Metadata
+ {metaModalContent} +
- ) - } - {/* ...rest of your UI... */} +
+ ) + } + {/* ...rest of your UI... */} -
- ) + +) } diff --git a/dicom-viewer/src/main.tsx b/dicom-viewer/src/main.tsx index cde20d2..40b3845 100644 --- a/dicom-viewer/src/main.tsx +++ b/dicom-viewer/src/main.tsx @@ -52,15 +52,22 @@ export function mountDicomViewers() { if (Array.isArray(parsed)) { const out: any[] = []; - parsed.forEach(item => { + parsed.forEach((item, itemIdx) => { // Case object with nested stacks: { caseId, stacks: [ { name, imageIds } ] } - if (item && typeof item === 'object' && Array.isArray(item.stacks)) { + if (item && typeof item === "object" && Array.isArray(item.stacks)) { const caseId = item.caseId || item.caseUID || item.case || undefined; - item.stacks.forEach((s: any) => { + item.stacks.forEach((s: any, sIdx: number) => { let imageIds: string[] = []; if (Array.isArray(s)) imageIds = s; else if (Array.isArray(s.imageIds)) imageIds = s.imageIds; else if (Array.isArray(s.i)) imageIds = s.i; + + // Validation + if (!Array.isArray(imageIds) || imageIds.length === 0 || !imageIds.every(x => typeof x === "string")) { + console.warn(`data-named-stacks: skipping invalid nested stack at parsed[${itemIdx}].stacks[${sIdx}]`, s); + return; + } + out.push({ imageIds: toWadouri(imageIds), name: s.name || s.label || undefined, @@ -73,6 +80,12 @@ export function mountDicomViewers() { // Stack object: { imageIds, i, name, caseId } if (item && typeof item === 'object' && (Array.isArray(item.imageIds) || Array.isArray(item.i))) { const imageIds = Array.isArray(item.imageIds) ? item.imageIds : item.i; + + if (!Array.isArray(imageIds) || imageIds.length === 0 || !imageIds.every(x => typeof x === "string")) { + console.warn(`data-named-stacks: skipping invalid stack object at parsed[${itemIdx}]`, item); + return; + } + out.push({ imageIds: toWadouri(imageIds), name: item.name || item.label || undefined, @@ -86,6 +99,9 @@ export function mountDicomViewers() { out.push(toDescriptor(item)); return; } + + // Unknown / invalid entry + console.warn(`data-named-stacks: unknown or invalid entry at parsed[${itemIdx}] — skipping`, item); }); return out; }