Add directory loading support and enhance file handling in the viewer

This commit is contained in:
Ross
2026-03-16 12:31:42 +00:00
parent adff7caffd
commit 8faa7e9be4
4 changed files with 442 additions and 37 deletions
+211 -35
View File
@@ -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<HTMLInputElement | null>(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<File[]> {
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<HTMLInputElement | null>(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<string, any> = 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<any> = [];
for (const [key, g] of groups.entries()) {
const entries = g.entries as Array<any>;
// 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
×
</button>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}>
<label style={{ fontSize: 13 }}><input type="radio" name="folderLoadMode" value="add" checked={folderLoadMode==='add'} onChange={()=>setFolderLoadMode('add')} /> Add (append)</label>
<label style={{ fontSize: 13 }}><input type="radio" name="folderLoadMode" value="replace" checked={folderLoadMode==='replace'} onChange={()=>setFolderLoadMode('replace')} /> Replace all</label>
</div>
<button
style={{
padding: "10px 18px",
@@ -3798,10 +3964,20 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
marginBottom: "18px",
width: "100%",
}}
onClick={() => fileInputRef.current?.click()}
onClick={handleFolderPick}
>
Load DICOM Folder
</button>
<input
ref={folderInputRef}
type="file"
style={{ display: "none" }}
multiple
// Directory attribute for recursive folder selection (webkit based browsers)
// Note: keep accept off here so folder picker works reliably; filtering happens after selection
onChange={handleFilesSelected}
/>
{/* Keep original file input for non-directory file selection if used elsewhere */}
<input
ref={fileInputRef}
type="file"
+6 -2
View File
@@ -35,6 +35,7 @@ export function mountDicomViewers() {
containers.forEach((container) => {
const id = container.id || '';
let imageStacks: () => Promise<any[]>;
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');