Add directory loading support and enhance file handling in the viewer
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Empty Viewer Test</title>
|
||||||
|
<style>
|
||||||
|
html,body{height:100%;margin:0}
|
||||||
|
.dicom-viewer-root{width:100%;height:100vh;background:#222;color:#fff}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root" class="dicom-viewer-root" style="width:100%;height:100vh;"
|
||||||
|
data-auto-cache-stack="false"
|
||||||
|
data-empty="true"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Provide a small noop React Refresh preamble to avoid plugin-react preamble errors -->
|
||||||
|
<script>
|
||||||
|
// If plugin-react's preamble wasn't injected for some reason, provide no-op
|
||||||
|
// implementations so the runtime does not throw. This disables fast-refresh
|
||||||
|
// semantics for this page but keeps HMR from crashing.
|
||||||
|
window.$RefreshReg$ = window.$RefreshReg$ || function() {};
|
||||||
|
window.$RefreshSig$ = window.$RefreshSig$ || function() { return function(type) { return type; }; };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Load Directory Test</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, Arial; padding: 18px; background: #f6f6f8; color: #111 }
|
||||||
|
.controls { margin: 12px 0 }
|
||||||
|
.file-row { display:flex; gap:8px; align-items:center; padding:6px 8px; border-bottom:1px solid #eee }
|
||||||
|
.path { font-family: monospace; color:#444; flex:1 }
|
||||||
|
button { padding:6px 10px }
|
||||||
|
#fileList { border:1px solid #ddd; background:#fff; border-radius:6px; max-height:60vh; overflow:auto }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Directory Loader (recursive)</h2>
|
||||||
|
<p>Select a folder (uses `webkitdirectory`) — files will be read recursively.
|
||||||
|
This page will call <code>window.loadDicomStackFromFiles(files)</code> if available in the viewer.</p>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<input id="dirInput" type="file" webkitdirectory directory multiple />
|
||||||
|
<label style="margin-left:12px"><input id="onlyDcm" type="checkbox" checked /> Show only <code>.dcm</code></label>
|
||||||
|
<button id="loadSelected">Load Selected</button>
|
||||||
|
<button id="loadAll">Load All</button>
|
||||||
|
<button id="openViewerAndSend">Open Viewer & Send</button>
|
||||||
|
<button id="clear">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="fileList"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const input = document.getElementById('dirInput');
|
||||||
|
const fileListDiv = document.getElementById('fileList');
|
||||||
|
const onlyDcmChk = document.getElementById('onlyDcm');
|
||||||
|
|
||||||
|
function asArray(fileList){ return Array.prototype.slice.call(fileList || []); }
|
||||||
|
|
||||||
|
function renderFiles(files){
|
||||||
|
fileListDiv.innerHTML = '';
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
fileListDiv.innerHTML = '<div style="padding:12px;color:#666">No files selected</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
files.forEach((f, idx) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'file-row';
|
||||||
|
const cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
cb.checked = true;
|
||||||
|
cb.dataset.idx = idx;
|
||||||
|
|
||||||
|
const pathEl = document.createElement('div');
|
||||||
|
pathEl.className = 'path';
|
||||||
|
// webkitRelativePath shows the folder structure when directory input used
|
||||||
|
pathEl.textContent = f.webkitRelativePath || f.name;
|
||||||
|
|
||||||
|
const sizeEl = document.createElement('div');
|
||||||
|
sizeEl.style.width = '90px';
|
||||||
|
sizeEl.style.textAlign = 'right';
|
||||||
|
sizeEl.style.color = '#666';
|
||||||
|
sizeEl.textContent = (f.size/1024).toFixed(1)+' kB';
|
||||||
|
|
||||||
|
row.appendChild(cb);
|
||||||
|
row.appendChild(pathEl);
|
||||||
|
row.appendChild(sizeEl);
|
||||||
|
|
||||||
|
frag.appendChild(row);
|
||||||
|
});
|
||||||
|
fileListDiv.appendChild(frag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterFiles(files){
|
||||||
|
const onlyDcm = onlyDcmChk.checked;
|
||||||
|
if (!onlyDcm) return files;
|
||||||
|
return files.filter(f => f.name.toLowerCase().endsWith('.dcm'));
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('change', ()=>{
|
||||||
|
const all = asArray(input.files);
|
||||||
|
const filtered = filterFiles(all);
|
||||||
|
// Save filtered list on the element for retrieval
|
||||||
|
input._currentFiles = filtered;
|
||||||
|
renderFiles(filtered);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('loadSelected').addEventListener('click', ()=>{
|
||||||
|
const current = input._currentFiles || [];
|
||||||
|
if (!current.length) return alert('No files selected. Choose a folder first.');
|
||||||
|
// collect checked indices
|
||||||
|
const checked = Array.from(fileListDiv.querySelectorAll('input[type=checkbox]'))
|
||||||
|
.filter(cb=>cb.checked)
|
||||||
|
.map(cb=>parseInt(cb.dataset.idx,10))
|
||||||
|
.filter(n=>!Number.isNaN(n));
|
||||||
|
const chosen = checked.map(i=>current[i]).filter(Boolean);
|
||||||
|
if (!chosen.length) return alert('No files checked.');
|
||||||
|
sendToViewer(chosen);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('loadAll').addEventListener('click', ()=>{
|
||||||
|
const current = input._currentFiles || [];
|
||||||
|
if (!current.length) return alert('No files selected. Choose a folder first.');
|
||||||
|
sendToViewer(current);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('openViewerAndSend').addEventListener('click', async ()=>{
|
||||||
|
const current = input._currentFiles || [];
|
||||||
|
if (!current.length) return alert('No files selected. Choose a folder first.');
|
||||||
|
// Open the viewer root in a new tab/window
|
||||||
|
const viewerWin = window.open('/', '_blank');
|
||||||
|
if (!viewerWin) return alert('Failed to open viewer window (popup blocked).');
|
||||||
|
|
||||||
|
// Wait for the viewer window to expose loadDicomStackFromFiles
|
||||||
|
const timeoutMs = 15000;
|
||||||
|
const start = Date.now();
|
||||||
|
const poll = () => new Promise((resolve, reject) => {
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
try {
|
||||||
|
if (viewerWin && (viewerWin.loadDicomStackFromFiles || viewerWin.loadDicomStackFromWadouriList)) {
|
||||||
|
clearInterval(iv);
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
clearInterval(iv);
|
||||||
|
reject(new Error('Timed out waiting for viewer API'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// cross-origin or not ready
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await poll();
|
||||||
|
// call the function on the opened viewer window
|
||||||
|
if (viewerWin.loadDicomStackFromFiles) {
|
||||||
|
viewerWin.loadDicomStackFromFiles(current);
|
||||||
|
alert('Sent '+current.length+' file(s) to the newly opened viewer.');
|
||||||
|
} else if (viewerWin.loadDicomStackFromWadouriList) {
|
||||||
|
// fallback for remote URLs
|
||||||
|
const wadouriList = current.map(f => 'wadouri:' + URL.createObjectURL(f));
|
||||||
|
viewerWin.loadDicomStackFromWadouriList(wadouriList);
|
||||||
|
alert('Sent '+current.length+' wadouri image(s) to the newly opened viewer.');
|
||||||
|
} else {
|
||||||
|
alert('Viewer opened but API not available.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('openViewerAndSend failed', e);
|
||||||
|
alert('Timed out waiting for viewer to be ready. Check the viewer tab.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('clear').addEventListener('click', ()=>{
|
||||||
|
input.value = null;
|
||||||
|
input._currentFiles = [];
|
||||||
|
renderFiles([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendToViewer(files){
|
||||||
|
// Prefer calling the viewer global if present
|
||||||
|
if (window.loadDicomStackFromFiles && typeof window.loadDicomStackFromFiles === 'function') {
|
||||||
|
try {
|
||||||
|
// The viewer accepts FileList or File[] per typing; pass array
|
||||||
|
window.loadDicomStackFromFiles(files);
|
||||||
|
alert('Sent '+files.length+' file(s) to viewer (window.loadDicomStackFromFiles).');
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('loadDicomStackFromFiles failed', e);
|
||||||
|
alert('Viewer call failed (see console).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try posting to parent (if embedded)
|
||||||
|
if (window.parent && window.parent !== window) {
|
||||||
|
try {
|
||||||
|
window.parent.postMessage({ type: 'loadFiles', filesCount: files.length }, '*');
|
||||||
|
alert('Posted message to parent with '+files.length+' files.');
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('Viewer integration function `window.loadDicomStackFromFiles` not found on this page.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial empty render
|
||||||
|
renderFiles([]);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+211
-35
@@ -1255,6 +1255,83 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
}, [mouseToolBindings, ctrlMouseToolBindings]);
|
}, [mouseToolBindings, ctrlMouseToolBindings]);
|
||||||
|
|
||||||
const [sideMenuOpen, setSideMenuOpen] = useState(false);
|
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
|
// Add a ref to store selected files
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
@@ -1267,47 +1344,131 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
setOrderBySliceLocation(false);
|
setOrderBySliceLocation(false);
|
||||||
|
|
||||||
const fileArray = Array.from(files).sort((a, b) => a.name.localeCompare(b.name));
|
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.
|
// Parse each file to extract DICOM tags for grouping (SeriesInstanceUID / StudyInstanceUID)
|
||||||
const firstFile = fileArray[0];
|
const parsedEntries = await Promise.all(fileArray.map(async (file) => {
|
||||||
const isImageFile = firstFile.type && firstFile.type.startsWith('image/');
|
const isImage = file.type && file.type.startsWith('image/');
|
||||||
if (!isImageFile) {
|
let studyInstanceUID: string | undefined = undefined;
|
||||||
try {
|
let seriesInstanceUID: string | undefined = undefined;
|
||||||
const arrayBuffer = await firstFile.arrayBuffer();
|
let instanceNumber: number | undefined = undefined;
|
||||||
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
let sliceLocation: number | undefined = undefined;
|
||||||
studyInstanceUID = dataSet.string('x0020000d');
|
let parsedSuccessfully = false;
|
||||||
console.log("StudyInstanceUID:", studyInstanceUID);
|
if (!isImage) {
|
||||||
} catch (e) {
|
try {
|
||||||
// not DICOM or failed to parse; leave UID undefined
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
console.debug('handleFilesSelected: first file is not a DICOM or failed to parse', e);
|
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
|
// Build stacks from groups, sorting within each group by requested order
|
||||||
const imageIds = fileArray.map((file) => {
|
const stacks: Array<any> = [];
|
||||||
const isImage = file.type && file.type.startsWith('image/');
|
for (const [key, g] of groups.entries()) {
|
||||||
if (isImage) {
|
const entries = g.entries as Array<any>;
|
||||||
return `external:${URL.createObjectURL(file)}`;
|
// Sort: prefer sliceLocation (if ordering requested), then instanceNumber, then filename
|
||||||
}
|
entries.sort((a, b) => {
|
||||||
return `wadouri:${URL.createObjectURL(file)}`;
|
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 => {
|
if (stacks.length === 0) return; // nothing to do
|
||||||
const next = [...prev, { imageIds, studyInstanceUID }];
|
|
||||||
setViewportImageIds(vpPrev => {
|
if (folderLoadMode === 'replace') {
|
||||||
const vpNext = [...vpPrev];
|
// Replace everything currently loaded with this new stack
|
||||||
vpNext[0] = imageIds;
|
try {
|
||||||
return vpNext;
|
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 => {
|
setSelectedStackIdx(Array(MAX_VIEWPORTS).fill(0));
|
||||||
const selNext = [...selPrev];
|
// Cache metadata for all stacks (fire-and-forget)
|
||||||
selNext[0] = next.length - 1;
|
for (const s of stacks) {
|
||||||
return selNext;
|
// 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>
|
</button>
|
||||||
</div>
|
</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
|
<button
|
||||||
style={{
|
style={{
|
||||||
padding: "10px 18px",
|
padding: "10px 18px",
|
||||||
@@ -3798,10 +3964,20 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
marginBottom: "18px",
|
marginBottom: "18px",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={handleFolderPick}
|
||||||
>
|
>
|
||||||
Load DICOM Folder
|
Load DICOM Folder
|
||||||
</button>
|
</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
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function mountDicomViewers() {
|
|||||||
containers.forEach((container) => {
|
containers.forEach((container) => {
|
||||||
const id = container.id || '';
|
const id = container.id || '';
|
||||||
let imageStacks: () => Promise<any[]>;
|
let imageStacks: () => Promise<any[]>;
|
||||||
|
const isEmpty = container.getAttribute('data-empty') === 'true';
|
||||||
|
|
||||||
// New: support data-named-stacks (richer format) and fallback to data-images
|
// New: support data-named-stacks (richer format) and fallback to data-images
|
||||||
const dataNamed = container.getAttribute('data-named-stacks');
|
const dataNamed = container.getAttribute('data-named-stacks');
|
||||||
@@ -112,7 +113,9 @@ export function mountDicomViewers() {
|
|||||||
return [];
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
if (dataNamed) {
|
if (isEmpty) {
|
||||||
|
imageStacks = () => Promise.resolve([] as any[]);
|
||||||
|
} else if (dataNamed) {
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(dataNamed);
|
parsed = JSON.parse(dataNamed);
|
||||||
@@ -141,7 +144,8 @@ export function mountDicomViewers() {
|
|||||||
imageStacks = () => Promise.resolve([[]]);
|
imageStacks = () => Promise.resolve([[]]);
|
||||||
}
|
}
|
||||||
} else {
|
} 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');
|
const autoCacheStackAttr = container.getAttribute('data-auto-cache-stack');
|
||||||
|
|||||||
Reference in New Issue
Block a user