From 8faa7e9be4142444cbef8cf5f05a1de565839d94 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 16 Mar 2026 12:31:42 +0000 Subject: [PATCH] Add directory loading support and enhance file handling in the viewer --- dicom-viewer/public/empty-viewer.html | 29 +++ dicom-viewer/public/load-dir-test.html | 196 ++++++++++++++++++++ dicom-viewer/src/App.tsx | 246 +++++++++++++++++++++---- dicom-viewer/src/main.tsx | 8 +- 4 files changed, 442 insertions(+), 37 deletions(-) create mode 100644 dicom-viewer/public/empty-viewer.html create mode 100644 dicom-viewer/public/load-dir-test.html diff --git a/dicom-viewer/public/empty-viewer.html b/dicom-viewer/public/empty-viewer.html new file mode 100644 index 0000000..ad663a9 --- /dev/null +++ b/dicom-viewer/public/empty-viewer.html @@ -0,0 +1,29 @@ + + + + + + Empty Viewer Test + + + +
+ + + + + + + diff --git a/dicom-viewer/public/load-dir-test.html b/dicom-viewer/public/load-dir-test.html new file mode 100644 index 0000000..c9d4d10 --- /dev/null +++ b/dicom-viewer/public/load-dir-test.html @@ -0,0 +1,196 @@ + + + + + + Load Directory Test + + + +

Directory Loader (recursive)

+

Select a folder (uses `webkitdirectory`) — files will be read recursively. + This page will call window.loadDicomStackFromFiles(files) if available in the viewer.

+ +
+ + + + + + +
+ +
+ + + + diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index d9e4c36..53ed25f 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -1255,6 +1255,83 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer }, [mouseToolBindings, ctrlMouseToolBindings]); const [sideMenuOpen, setSideMenuOpen] = useState(false); + const [folderLoadMode, setFolderLoadMode] = useState<'add' | 'replace'>('add'); + const folderInputRef = useRef(null); + useEffect(() => { + // Some browsers don't accept non-standard props via JSX typings; set attributes directly. + try { + if (folderInputRef.current) { + folderInputRef.current.setAttribute('webkitdirectory', 'true'); + folderInputRef.current.setAttribute('directory', ''); + folderInputRef.current.setAttribute('mozdirectory', ''); + } + } catch (e) { + // ignore + } + }, []); + + // Recursively collect files from a DirectoryHandle (File System Access API) + async function collectFilesFromDirectoryHandle(dirHandle: any, pathPrefix = ''): Promise { + const files: File[] = []; + // Prefer entries() which yields [name, handle] pairs — more explicit + const entriesIter = (dirHandle as any).entries ? (dirHandle as any).entries() : (dirHandle as any).values(); + for await (const entryPair of entriesIter) { + try { + // entryPair may be [name, handle] or a handle directly + let entry: any; + let entryName: string | undefined; + if (Array.isArray(entryPair) && entryPair.length >= 2) { + entryName = entryPair[0]; + entry = entryPair[1]; + } else { + entry = entryPair; + entryName = entry.name; + } + + if (!entry) continue; + + if (entry.kind === 'file') { + const f = await entry.getFile(); + try { + Object.defineProperty(f, 'webkitRelativePath', { value: pathPrefix + (entryName || entry.name), configurable: true }); + } catch (e) { + // ignore if not possible + } + files.push(f); + } else if (entry.kind === 'directory') { + // Recurse into subdirectory + const nested = await collectFilesFromDirectoryHandle(entry, pathPrefix + (entryName || entry.name) + '/'); + files.push(...nested); + } + } catch (e) { + // ignore individual entry errors + console.debug('collectFilesFromDirectoryHandle: skipping entry due to error', e); + } + } + return files; + } + + // Try to open the native directory picker when available, otherwise fall back to the hidden input + const handleFolderPick = async () => { + if ((window as any).showDirectoryPicker) { + try { + const dirHandle = await (window as any).showDirectoryPicker(); + const files = await collectFilesFromDirectoryHandle(dirHandle); + if (files && files.length > 0) { + // Call existing file handler with our array + await handleFilesSelected({ target: { files } } as any); + } else { + alert('No files found in selected folder.'); + } + return; + } catch (e) { + console.warn('showDirectoryPicker failed or cancelled', e); + // fallthrough to input click + } + } + // Fallback: click the folder input (webkitdirectory attribute should be set at runtime) + folderInputRef.current?.click(); + }; // Add a ref to store selected files const fileInputRef = useRef(null); @@ -1267,47 +1344,131 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer setOrderBySliceLocation(false); const fileArray = Array.from(files).sort((a, b) => a.name.localeCompare(b.name)); - let studyInstanceUID: string | undefined = undefined; - // If the first file is likely a DICOM (no mime or .dcm extension), try to parse. - const firstFile = fileArray[0]; - const isImageFile = firstFile.type && firstFile.type.startsWith('image/'); - if (!isImageFile) { - try { - const arrayBuffer = await firstFile.arrayBuffer(); - const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); - studyInstanceUID = dataSet.string('x0020000d'); - console.log("StudyInstanceUID:", studyInstanceUID); - } catch (e) { - // not DICOM or failed to parse; leave UID undefined - console.debug('handleFilesSelected: first file is not a DICOM or failed to parse', e); + // Parse each file to extract DICOM tags for grouping (SeriesInstanceUID / StudyInstanceUID) + const parsedEntries = await Promise.all(fileArray.map(async (file) => { + const isImage = file.type && file.type.startsWith('image/'); + let studyInstanceUID: string | undefined = undefined; + let seriesInstanceUID: string | undefined = undefined; + let instanceNumber: number | undefined = undefined; + let sliceLocation: number | undefined = undefined; + let parsedSuccessfully = false; + if (!isImage) { + try { + const arrayBuffer = await file.arrayBuffer(); + const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); + studyInstanceUID = dataSet.string('x0020000d'); + seriesInstanceUID = dataSet.string('x0020000e'); + const instStr = dataSet.string('x00200013'); + if (instStr !== undefined && instStr !== null && instStr !== '') instanceNumber = parseInt(instStr as string, 10); + const sliceStr = dataSet.string('x00201041') || dataSet.string('x00201040'); + if (sliceStr !== undefined && sliceStr !== null && sliceStr !== '') sliceLocation = parseFloat(sliceStr as string); + parsedSuccessfully = true; + } catch (e) { + // Not a DICOM or failed to parse + parsedSuccessfully = false; + } } + const imageId = isImage ? `external:${URL.createObjectURL(file)}` : `wadouri:${URL.createObjectURL(file)}`; + return { file, imageId, studyInstanceUID, seriesInstanceUID, instanceNumber, sliceLocation, isImage, parsedSuccessfully }; + })); + + // Group parsed entries by SeriesInstanceUID (fallback to StudyInstanceUID or filename folder) + const groups: Map = new Map(); + for (const entry of parsedEntries) { + // Skip files that are neither images nor successfully parsed DICOMs + if (!entry.isImage && !entry.parsedSuccessfully) { + console.debug('Skipping non-DICOM/non-image file:', entry.file.name); + continue; + } + const key = entry.seriesInstanceUID || entry.studyInstanceUID || (entry.file.webkitRelativePath ? entry.file.webkitRelativePath.split('/')[0] : entry.file.name); + if (!groups.has(key)) { + groups.set(key, { entries: [], studyInstanceUID: entry.studyInstanceUID, seriesInstanceUID: entry.seriesInstanceUID }); + } + groups.get(key).entries.push(entry); } - // Create imageIds: use wadouri: for DICOM files, `external:` for image files - const imageIds = fileArray.map((file) => { - const isImage = file.type && file.type.startsWith('image/'); - if (isImage) { - return `external:${URL.createObjectURL(file)}`; - } - return `wadouri:${URL.createObjectURL(file)}`; - }); + // Build stacks from groups, sorting within each group by requested order + const stacks: Array = []; + for (const [key, g] of groups.entries()) { + const entries = g.entries as Array; + // Sort: prefer sliceLocation (if ordering requested), then instanceNumber, then filename + entries.sort((a, b) => { + if (orderBySliceLocation && a.sliceLocation !== undefined && b.sliceLocation !== undefined) return (a.sliceLocation - b.sliceLocation); + if (a.instanceNumber !== undefined && b.instanceNumber !== undefined) return (a.instanceNumber - b.instanceNumber); + return (a.file.webkitRelativePath || a.file.name).localeCompare(b.file.webkitRelativePath || b.file.name); + }); + const imageIds = entries.map(e => e.imageId); + stacks.push({ imageIds, studyInstanceUID: g.studyInstanceUID, seriesInstanceUID: g.seriesInstanceUID }); + } - setAvailableStacks(prev => { - const next = [...prev, { imageIds, studyInstanceUID }]; - setViewportImageIds(vpPrev => { - const vpNext = [...vpPrev]; - vpNext[0] = imageIds; - return vpNext; + if (stacks.length === 0) return; // nothing to do + + if (folderLoadMode === 'replace') { + // Replace everything currently loaded with this new stack + try { + const renderingEngine = renderingEngineRef.current; + if (renderingEngine) { + for (let i = 0; i < MAX_VIEWPORTS; i++) { + const viewportId = `CT_${i}`; + try { + if (renderingEngine.getViewport(viewportId)) { + renderingEngine.disableElement(viewportId); + } + } catch (e) { /* ignore */ } + try { + const el = viewportRefs.current[i]; + if (el) { + try { if ((el as any)._externalPanZoom && (el as any)._externalPanZoom.cleanup) (el as any)._externalPanZoom.cleanup(); } catch (e) {} + try { if ((el as any)._resizeObserver) { (el as any)._resizeObserver.disconnect(); (el as any)._resizeObserver = undefined; } } catch (e) {} + el.innerHTML = ''; + (el as any)._cornerstoneEnabled = false; + (el as any)._lastViewportType = undefined; + } + } catch (e) { /* ignore */ } + } + } + } catch (e) { /* ignore */ } + + // Replace with the grouped stacks + setAvailableStacks(stacks); + // Populate viewports with the first N stacks (repeat first if fewer stacks than viewports) + setViewportImageIds(() => { + const arr = Array(MAX_VIEWPORTS).fill([] as string[]); + for (let i = 0; i < MAX_VIEWPORTS; i++) { + arr[i] = stacks[i] ? stacks[i].imageIds : stacks[0].imageIds; + } + return arr; }); - setSelectedStackIdx(selPrev => { - const selNext = [...selPrev]; - selNext[0] = next.length - 1; - return selNext; + setSelectedStackIdx(Array(MAX_VIEWPORTS).fill(0)); + // Cache metadata for all stacks (fire-and-forget) + for (const s of stacks) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + setLoadedImageIdsAndCacheMeta(s.imageIds); + } + } else { + // Append (add) all grouped stacks + setAvailableStacks(prev => { + const next = [...prev, ...stacks]; + // set first viewport to the last appended stack + setViewportImageIds(vpPrev => { + const vpNext = [...vpPrev]; + vpNext[0] = stacks[stacks.length - 1].imageIds; + return vpNext; + }); + setSelectedStackIdx(selPrev => { + const selNext = [...selPrev]; + selNext[0] = next.length - 1; + return selNext; + }); + // Cache metadata for appended stacks + for (const s of stacks) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + setLoadedImageIdsAndCacheMeta(s.imageIds); + } + return next; }); - setLoadedImageIdsAndCacheMeta(imageIds); - return next; - }); + } }; @@ -3785,6 +3946,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer × +
+ + +
+ + + {/* Keep original file input for non-directory file selection if used elsewhere */} { const id = container.id || ''; let imageStacks: () => Promise; + const isEmpty = container.getAttribute('data-empty') === 'true'; // New: support data-named-stacks (richer format) and fallback to data-images const dataNamed = container.getAttribute('data-named-stacks'); @@ -112,7 +113,9 @@ export function mountDicomViewers() { return []; }; - if (dataNamed) { + if (isEmpty) { + imageStacks = () => Promise.resolve([] as any[]); + } else if (dataNamed) { let parsed; try { parsed = JSON.parse(dataNamed); @@ -141,7 +144,8 @@ export function mountDicomViewers() { imageStacks = () => Promise.resolve([[]]); } } else { - imageStacks = () => Promise.resolve([[]]); + // No data provided: return no stacks (empty array) so viewer starts empty + imageStacks = () => Promise.resolve([] as any[]); } const autoCacheStackAttr = container.getAttribute('data-auto-cache-stack');