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');