From 13361a30a53d4b47548b82bc4feb2f0218d75486 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 18 May 2026 14:07:33 +0100 Subject: [PATCH] Refactor DICOM viewer mounting logic and enhance container management - Introduced a new `mountContainer` function to handle individual container mounting and unmounting. - Added `shouldMountContainer` to determine if a container should be mounted based on provided options. - Enhanced `parseToDescriptors` to improve parsing of image stack data, supporting both named stacks and plain arrays. - Updated `mountDicomViewers` to utilize the new mounting logic and streamline container processing. - Exposed `remountDicomViewer` globally for dynamic remounting of specific containers. - Removed redundant debug logging and improved error handling for invalid data. --- dicom-viewer/src/App.tsx | 524 +++++++++++++++----------------------- dicom-viewer/src/main.tsx | 290 ++++++++++----------- 2 files changed, 342 insertions(+), 472 deletions(-) diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index a82f389..2e93620 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -30,7 +30,6 @@ declare global { cornerstone3dTools: typeof cornerstoneTools; cornerstone3d: typeof cornerstone3d; cornerstoneDICOMImageLoader: typeof cornerstoneDICOMImageLoader; - __DV3D_THUMB_VERBOSE__?: boolean; } } @@ -161,16 +160,8 @@ const TOOL_OPTIONS = [ { label: "Reference Cursors", value: ReferenceCursors.toolName, icon: "⦿" }, ]; -const THUMB_DEBUG_BUFFER_MAX = 500; const THUMB_CAPTURE_MAX_RETRIES = 3; const THUMB_CAPTURE_RETRY_BASE_MS = 220; -const THUMB_DEBUG_HIGH_FREQ_EVENTS = new Set([ - 'stackThumbnails-state', - 'thumbnail-generation-check', - 'thumbnail-candidate', - 'thumbnail-render-source', - 'thumbnail-img-load', -]); function getToolIcon(toolName?: string) { if (!toolName) return '🔖'; @@ -184,21 +175,6 @@ function stripWadouriPrefix(imageId?: string): string { return imageId.replace(/^wadouri:+/, "").replace(/^:+/, ""); } -function truncateForDebug(value: string, maxLen = 180): string { - if (!value) return value; - if (value.length <= maxLen) return value; - return `${value.slice(0, maxLen)}...(${value.length} chars)`; -} - -function isLikelyDicomThumbnailSource(src?: string | null): boolean { - if (!src) return false; - const lower = src.toLowerCase(); - if (lower.startsWith('wadouri:')) return true; - if (lower.includes('.dcm')) return true; - if (lower.includes('/dicom') || lower.includes('/wado')) return true; - return false; -} - function isCanvasLikelyReadyForThumbnail(canvas: HTMLCanvasElement): boolean { try { const ctx = canvas.getContext('2d', { willReadFrequently: true } as CanvasRenderingContext2DSettings); @@ -322,6 +298,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const [availableStacks, setAvailableStacks] = useState([]); const [activeViewport, setActiveViewport] = useState(0); const [draggedStackIdx, setDraggedStackIdx] = useState(null); + const [externalDragActive, setExternalDragActive] = useState(false); const [dropPreview, setDropPreview] = useState<{ viewportIdx: number; zone: ViewportDropZone } | null>(null); // After your useState for viewportImageIds: const [viewportImageIds, _setViewportImageIds] = useState( @@ -353,59 +330,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const [stackThumbnails, setStackThumbnails] = useState>({}); const stackThumbnailsRef = useRef>({}); const thumbRequestedRef = useRef>({}); - const thumbDebugPrefix = `[DV3D Thumb][${container_id || 'default'}]`; - const thumbDebugBufferRef = useRef>>([]); - const thumbDebugLastLogRef = useRef>({}); - - const thumbDebug = useCallback((event: string, details?: Record) => { - try { - const payload = details || {}; - const isHighFrequency = THUMB_DEBUG_HIGH_FREQ_EVENTS.has(event); - const stackPart = typeof payload.stackIdx === 'number' ? String(payload.stackIdx) : (typeof payload.idx === 'number' ? String(payload.idx) : 'global'); - const key = `${event}:${stackPart}`; - const now = Date.now(); - const last = thumbDebugLastLogRef.current[key] || 0; - const verbose = typeof window !== 'undefined' && !!window.__DV3D_THUMB_VERBOSE__; - - if (isHighFrequency && !verbose && now - last < 2500) { - return; - } - - thumbDebugLastLogRef.current[key] = now; - - const entry = { - ts: new Date().toISOString(), - event, - container: container_id || 'default', - path: typeof window !== 'undefined' ? window.location.pathname : '', - details: payload, - }; - - thumbDebugBufferRef.current.push(entry); - if (thumbDebugBufferRef.current.length > THUMB_DEBUG_BUFFER_MAX) { - thumbDebugBufferRef.current.shift(); - } - - console.debug(thumbDebugPrefix, event, payload); - } catch (e) { - // ignore debug logging failures - } - }, [thumbDebugPrefix, container_id]); const setStackThumbnail = useCallback((stackIdx: number, value: string) => { setStackThumbnails(prev => { const previous = prev[stackIdx]; - try { - console.debug(thumbDebugPrefix, 'setStackThumbnail', { - stackIdx, - previous: previous ? truncateForDebug(previous, 120) : null, - next: value ? truncateForDebug(value, 120) : null, - previousWasBlob: !!(previous && previous.startsWith('blob:')), - nextIsBlob: !!(value && value.startsWith('blob:')), - }); - } catch (e) { - // ignore debug logging failures - } if (previous && previous !== value && previous.startsWith('blob:')) { try { URL.revokeObjectURL(previous); @@ -417,18 +345,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer stackThumbnailsRef.current = next; return next; }); - }, [thumbDebugPrefix]); + }, []); useEffect(() => { stackThumbnailsRef.current = stackThumbnails; - const entries = Object.entries(stackThumbnails).map(([idx, value]) => ({ - idx: Number(idx), - value: truncateForDebug(value, 100), - isBlob: value.startsWith('blob:'), - isBroken: value === 'BROKEN', - })); - thumbDebug('stackThumbnails-state', { count: entries.length, entries }); - }, [stackThumbnails, thumbDebug]); + }, [stackThumbnails]); useEffect(() => { return () => { @@ -813,23 +734,6 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer }) ); - thumbDebug('imageStacks-loaded', { - descriptorCount: stacksRaw.length, - normalizedCount: normalized.length, - summary: normalized.map((stack, idx) => { - const ids = Array.isArray(stack.imageIds) ? stack.imageIds : []; - const mid = ids[Math.floor(ids.length / 2)] || ids[0] || ''; - return { - idx, - name: stack.name || null, - caseId: stack.caseId || null, - studyId: stack.studyId || null, - imageCount: ids.length, - sampleImageId: truncateForDebug(mid, 180), - }; - }), - }); - setAvailableStacks(normalized); // Seed viewports with first stack if present (preserve old behavior) @@ -842,15 +746,12 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer }) .catch(err => { console.error("Failed to load imageStacks:", err); - thumbDebug('imageStacks-load-failed', { - error: String((err as any)?.message || err), - }); setAvailableStacks([]); setViewportImageIds(Array(MAX_VIEWPORTS).fill([])); }); return () => { mounted = false; }; - }, [thumbDebug]); + }, []); const handleLoadStack = async (viewportIdx: number, stackIdx: number) => { const stackObj = availableStacks[stackIdx]; @@ -934,32 +835,17 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const generateThumbnail = useCallback(async (stackIdx: number, chosenImageId: string | null, retryAttempt = 0) => { if (!chosenImageId) { - console.warn('generateThumbnail: no chosenImageId for stack', stackIdx); - thumbDebug('generate-skip-no-image', { stackIdx }); - return; - } - // avoid regenerating - if (stackThumbnails[stackIdx]) { - console.debug('generateThumbnail: thumbnail already exists for', stackIdx); - thumbDebug('generate-skip-existing', { - stackIdx, - existing: truncateForDebug(stackThumbnails[stackIdx], 120), - }); - // already have it - return; - } - if (thumbRequestedRef.current[stackIdx]) { - console.debug('generateThumbnail: already requested for', stackIdx); - thumbDebug('generate-skip-requested', { stackIdx }); return; } - // If the chosen image is an external non-DICOM image, draw it to a canvas directly + if (stackThumbnails[stackIdx]) { + return; + } + if (thumbRequestedRef.current[stackIdx]) { + return; + } + if (isExternalImageId(chosenImageId)) { - thumbDebug('generate-external-start', { - stackIdx, - chosenImageId: truncateForDebug(chosenImageId, 180), - }); try { const img = new Image(); img.crossOrigin = 'anonymous'; @@ -975,7 +861,6 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer canvas.height = size; const ctx = canvas.getContext('2d'); if (ctx) { - // draw with aspect-fit const ratio = Math.min(size / img.width, size / img.height); const w = img.width * ratio; const h = img.height * ratio; @@ -984,18 +869,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer ctx.fillStyle = '#000'; ctx.fillRect(0, 0, size, size); ctx.drawImage(img, x, y, w, h); - const dataUrl = canvas.toDataURL('image/png'); - setStackThumbnail(stackIdx, dataUrl); + setStackThumbnail(stackIdx, canvas.toDataURL('image/png')); } else { setStackThumbnail(stackIdx, 'BROKEN'); } } catch (err) { - console.warn('generateThumbnail: failed to generate thumbnail for external image', err); - thumbDebug('generate-external-failed', { - stackIdx, - chosenImageId: truncateForDebug(chosenImageId, 180), - error: String((err as any)?.message || err), - }); setStackThumbnail(stackIdx, 'BROKEN'); } return; @@ -1003,24 +881,12 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const renderingEngine = renderingEngineRef.current; if (!renderingEngine) { - console.warn('generateThumbnail: no renderingEngine yet for', stackIdx); - thumbDebug('generate-skip-no-engine', { - stackIdx, - chosenImageId: truncateForDebug(chosenImageId, 180), - }); return; } - // mark requested once we are sure the rendering engine exists + thumbRequestedRef.current[stackIdx] = true; - console.debug('generateThumbnail: starting for', stackIdx, chosenImageId); const viewportId = `THUMB_${container_id || 'main'}_${stackIdx}`; - thumbDebug('generate-start', { - stackIdx, - chosenImageId: truncateForDebug(chosenImageId, 180), - viewportId, - retryAttempt, - }); const el = document.createElement('div'); el.style.width = '160px'; el.style.height = '160px'; @@ -1033,30 +899,20 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer renderingEngine.enableElement({ viewportId, type: Enums.ViewportType.STACK, element: el }); const vp = renderingEngine.getViewport(viewportId); if (!vp) { - console.warn('generateThumbnail: no viewport returned after enableElement for', viewportId); - thumbDebug('generate-failed-no-viewport', { stackIdx, viewportId }); return; } - // Use single-image stack for thumbnail + try { vp.setStack([chosenImageId]); } catch (err) { - console.warn('generateThumbnail: vp.setStack failed', err); - thumbDebug('generate-setStack-failed', { - stackIdx, - viewportId, - error: String((err as any)?.message || err), - }); + // ignore setStack failures for fallback behavior } - // Wait for IMAGE_RENDERED event on the temporary viewport element or timeout - const canvasReady = await new Promise(resolve => { + await new Promise(resolve => { let resolved = false; const timeout = setTimeout(() => { if (!resolved) { resolved = true; - console.warn('generateThumbnail: IMAGE_RENDERED timeout for', viewportId); - thumbDebug('generate-render-timeout', { stackIdx, viewportId }); resolve(false); } }, 2000); @@ -1065,26 +921,13 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer if (resolved) return; resolved = true; clearTimeout(timeout); - console.debug('generateThumbnail: IMAGE_RENDERED received for', viewportId); - thumbDebug('generate-rendered', { stackIdx, viewportId }); resolve(true); }; try { if (vp && vp.element) { - try { - vp.element.addEventListener(IMAGE_RENDERED, onRendered, { once: true } as any); - } catch (err) { - console.warn('generateThumbnail: failed to add IMAGE_RENDERED listener', err); - thumbDebug('generate-listener-failed', { - stackIdx, - viewportId, - error: String((err as any)?.message || err), - }); - } + vp.element.addEventListener(IMAGE_RENDERED, onRendered, { once: true } as any); } - // Trigger a render in case it doesn't auto-render. - // vp.render may return undefined (not a Promise), so guard before calling .catch() if (typeof vp.render === 'function') { try { const res = vp.render(); @@ -1092,60 +935,30 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer (res as any).catch(() => undefined); } } catch (err) { - // ignore render errors here + // ignore render errors } } } catch (err) { clearTimeout(timeout); - console.warn('generateThumbnail: error while waiting for render', err); - thumbDebug('generate-render-wait-error', { - stackIdx, - viewportId, - error: String((err as any)?.message || err), - }); resolve(false); } }); - // Find canvas inside the temporary element or viewport element. Try multiple locations. let canvas = findCanvasIn(el) || (vp && vp.element ? findCanvasIn(vp.element) : null); if (!canvas) { - console.debug('generateThumbnail: no canvas found immediately, waiting briefly and retrying for', viewportId, 'canvasReady=', canvasReady); - thumbDebug('generate-canvas-missing-initial', { stackIdx, viewportId, canvasReady }); await new Promise(r => setTimeout(r, 150)); canvas = findCanvasIn(el) || (vp && vp.element ? findCanvasIn(vp.element) : null); } if (canvas) { - thumbDebug('generate-canvas-found', { - stackIdx, - viewportId, - canvasWidth: canvas.width, - canvasHeight: canvas.height, - }); - const readyForCapture = isCanvasLikelyReadyForThumbnail(canvas); - if (!readyForCapture) { - if (retryAttempt < THUMB_CAPTURE_MAX_RETRIES) { - const retryDelayMs = THUMB_CAPTURE_RETRY_BASE_MS * (retryAttempt + 1); - thumbDebug('generate-canvas-not-ready-retry', { - stackIdx, - viewportId, - retryAttempt, - retryDelayMs, - }); - thumbRequestedRef.current[stackIdx] = false; - setTimeout(() => { - generateThumbnail(stackIdx, chosenImageId, retryAttempt + 1); - }, retryDelayMs); - return; - } - - thumbDebug('generate-canvas-not-ready-final-attempt', { - stackIdx, - viewportId, - retryAttempt, - }); + if (!readyForCapture && retryAttempt < THUMB_CAPTURE_MAX_RETRIES) { + const retryDelayMs = THUMB_CAPTURE_RETRY_BASE_MS * (retryAttempt + 1); + thumbRequestedRef.current[stackIdx] = false; + setTimeout(() => { + generateThumbnail(stackIdx, chosenImageId, retryAttempt + 1); + }, retryDelayMs); + return; } try { @@ -1160,87 +973,41 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer }, 'image/png'); }); if (blobUrl) { - thumbDebug('generate-toBlob-success', { - stackIdx, - viewportId, - blobUrl: truncateForDebug(blobUrl, 120), - }); setStackThumbnail(stackIdx, blobUrl); } else { - thumbDebug('generate-toBlob-empty-fallback-dataUrl', { stackIdx, viewportId }); setStackThumbnail(stackIdx, canvas.toDataURL('image/png')); } } else { - thumbDebug('generate-no-toBlob-fallback-dataUrl', { stackIdx, viewportId }); setStackThumbnail(stackIdx, canvas.toDataURL('image/png')); } - console.debug('generateThumbnail: thumbnail captured for stack', stackIdx); - thumbDebug('generate-success', { stackIdx, viewportId }); } catch (err) { - console.warn('generateThumbnail: toDataURL failed', err); - thumbDebug('generate-data-extract-failed', { - stackIdx, - viewportId, - error: String((err as any)?.message || err), - }); - // Mark as broken so UI shows placeholder instead of attempting to load raw DICOM URL setStackThumbnail(stackIdx, 'BROKEN'); } } else { - console.warn('generateThumbnail: no canvas found after render for', viewportId, 'canvasReady=', canvasReady, 'vp.element=', !!(vp && vp.element)); - thumbDebug('generate-failed-no-canvas', { - stackIdx, - viewportId, - canvasReady, - hasViewportElement: !!(vp && vp.element), - }); - // Mark as broken so we don't attempt to use raw .dcm URL as src setStackThumbnail(stackIdx, 'BROKEN'); } } catch (err) { - // log and mark broken so UI shows placeholder - console.warn('Thumbnail generation failed for', chosenImageId, err); - thumbDebug('generate-failed', { - stackIdx, - chosenImageId: truncateForDebug(chosenImageId || '', 180), - error: String((err as any)?.message || err), - }); try { setStackThumbnail(stackIdx, 'BROKEN'); } catch (e) { /* ignore */ } } finally { - try { renderingEngine.disableElement(viewportId); } catch (e) { - console.warn('Failed to disable thumbnail element:', e); - } - thumbDebug('generate-cleanup', { stackIdx, viewportId }); + try { renderingEngine.disableElement(viewportId); } catch (e) { /* ignore */ } if (el && el.parentNode) el.parentNode.removeChild(el); } - }, [container_id, stackThumbnails, setStackThumbnail, thumbDebug]); + }, [container_id, stackThumbnails, setStackThumbnail]); // Kick off thumbnail generation when stacks change useEffect(() => { if (!availableStacks || availableStacks.length === 0) return; - thumbDebug('thumbnail-generation-check', { - stackCount: availableStacks.length, - requested: { ...thumbRequestedRef.current }, - thumbnailKeys: Object.keys(stackThumbnails), - }); const renderingEngine = renderingEngineRef.current; if (!renderingEngine) return; // wait until rendering engine exists availableStacks.forEach((stack, idx) => { const imageIds = Array.isArray(stack.imageIds) ? stack.imageIds : []; const midIndex = Math.floor(imageIds.length / 2); const chosen = imageIds[midIndex] || imageIds[0] || null; - thumbDebug('thumbnail-candidate', { - idx, - imageCount: imageIds.length, - chosen: truncateForDebug(chosen || '', 180), - hasThumbnail: !!stackThumbnails[idx], - requested: !!thumbRequestedRef.current[idx], - }); if (!stackThumbnails[idx] && !thumbRequestedRef.current[idx]) { generateThumbnail(idx, chosen as string | null); } }); - }, [availableStacks, generateThumbnail, stackThumbnails, thumbDebug]); + }, [availableStacks, generateThumbnail, stackThumbnails]); // Wrap setLoadedImageIds to also cache DICOM metadata const setLoadedImageIdsAndCacheMeta = async (imageIds: string[], options?: { force?: boolean }) => { @@ -1539,18 +1306,25 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer useEffect(() => { window.loadDicomStackFromFiles = (files: FileList | File[]) => { - // Convert FileList to array if needed const fileArray = Array.from(files); - // Your logic to handle the files and load them as a stack - // For example, call your existing handler: handleFilesSelected({ target: { files: fileArray } } as any); }; - // Cleanup on unmount return () => { window.loadDicomStackFromFiles = undefined; }; }, []); + // Clean up external drag state if the drag ends outside any viewer viewport. + useEffect(() => { + const onDocDragEnd = () => { + setExternalDragActive(false); + setDropPreview(null); + setDraggedStackIdx(null); + }; + document.addEventListener('dragend', onDocDragEnd); + return () => document.removeEventListener('dragend', onDocDragEnd); + }, []); + const [altPressed, setAltPressed] = useState(false); const [shiftPressed, setShiftPressed] = useState(false); @@ -2187,6 +1961,113 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer } } + /** + * Load an external stack (from outside the viewer, e.g. a series block dragged + * from the page) into the given viewport, optionally splitting the grid. + * The stack is added to availableStacks so it appears in the side panel. + */ + const applyExternalDrop = async ( + viewportIdx: number, + rawImageIds: string[], + name: string, + requestedZone: ViewportDropZone, + ) => { + const imageIds = rawImageIds.map(url => + url.startsWith('wadouri:') ? url : `wadouri:${url}` + ); + if (imageIds.length === 0) return; + + // Add the incoming stack to the panel and capture its index. + let newStackIdx = 0; + setAvailableStacks(prev => { + newStackIdx = prev.length; + return [...prev, { imageIds, name }]; + }); + + const resolvedZone = resolveDropZone(requestedZone); + if (resolvedZone === 'center') { + setLoadingState('displayset'); + try { + setViewportModes(prev => { const n = [...prev]; n[viewportIdx] = 'stack'; return n; }); + setViewportImageIds(prev => { const n = [...prev]; n[viewportIdx] = imageIds; return n; }); + setSelectedStackIdx(prev => { const n = [...prev]; n[viewportIdx] = newStackIdx; return n; }); + setActiveViewport(viewportIdx); + await setLoadedImageIdsAndCacheMeta(imageIds); + } finally { + setLoadingState(null); + } + return; + } + + const oldRows = viewportGrid.rows; + const oldCols = viewportGrid.cols; + const row = Math.floor(viewportIdx / oldCols); + const col = viewportIdx % oldCols; + const newRows = (resolvedZone === 'top' || resolvedZone === 'bottom') ? oldRows + 1 : oldRows; + const newCols = (resolvedZone === 'left' || resolvedZone === 'right') ? oldCols + 1 : oldCols; + + if (newRows > 3 || newCols > 3 || newRows * newCols > MAX_VIEWPORTS) { + // Grid already full — just load into the target viewport. + setLoadingState('displayset'); + try { + setViewportModes(prev => { const n = [...prev]; n[viewportIdx] = 'stack'; return n; }); + setViewportImageIds(prev => { const n = [...prev]; n[viewportIdx] = imageIds; return n; }); + setSelectedStackIdx(prev => { const n = [...prev]; n[viewportIdx] = newStackIdx; return n; }); + setActiveViewport(viewportIdx); + await setLoadedImageIdsAndCacheMeta(imageIds); + } finally { + setLoadingState(null); + } + return; + } + + const targetNewRow = resolvedZone === 'top' ? row : resolvedZone === 'bottom' ? row + 1 : row; + const targetNewCol = resolvedZone === 'left' ? col : resolvedZone === 'right' ? col + 1 : col; + const insertedViewportIndex = targetNewRow * newCols + targetNewCol; + + const nextImageIds = Array(MAX_VIEWPORTS).fill([] as string[]); + const nextModes = Array(MAX_VIEWPORTS).fill('stack') as Array<'stack' | 'volume'>; + const nextSelected = Array(MAX_VIEWPORTS).fill(0); + + for (let oldIdx = 0; oldIdx < oldRows * oldCols; oldIdx++) { + const oldRow = Math.floor(oldIdx / oldCols); + const oldCol = oldIdx % oldCols; + let mappedRow = oldRow; + let mappedCol = oldCol; + if (resolvedZone === 'left' || resolvedZone === 'right') { + if (oldRow === row) { + mappedCol = oldCol < col ? oldCol : oldCol > col ? oldCol + 1 : (resolvedZone === 'left' ? oldCol + 1 : oldCol); + } + } + if (resolvedZone === 'top' || resolvedZone === 'bottom') { + if (oldCol === col) { + mappedRow = oldRow < row ? oldRow : oldRow > row ? oldRow + 1 : (resolvedZone === 'top' ? oldRow + 1 : oldRow); + } + } + const mappedIdx = mappedRow * newCols + mappedCol; + nextImageIds[mappedIdx] = viewportImageIds[oldIdx] || []; + nextModes[mappedIdx] = viewportModes[oldIdx] || 'stack'; + nextSelected[mappedIdx] = selectedStackIdx[oldIdx] ?? 0; + } + + nextImageIds[insertedViewportIndex] = imageIds; + nextModes[insertedViewportIndex] = 'stack'; + nextSelected[insertedViewportIndex] = newStackIdx; + + setLoadingState('displayset'); + try { + saveVisibleViewportState(); + setViewportGrid({ rows: newRows, cols: newCols }); + setViewportImageIds(nextImageIds); + setViewportModes(nextModes); + setSelectedStackIdx(nextSelected); + setActiveViewport(insertedViewportIndex); + await setLoadedImageIdsAndCacheMeta(imageIds); + } finally { + setLoadingState(null); + } + }; + const applyStackDrop = async (viewportIdx: number, stackIdx: number, requestedZone: ViewportDropZone) => { const stackObj = availableStacks[stackIdx]; if (!stackObj) return; @@ -3318,6 +3199,33 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer setLoadedImageIdsAndCacheMeta(imageIds); }; + /** + * Load an external stack (raw media URLs or wadouri: IDs) into the active + * viewport of this viewer, adding it to the side-panel stack list. + * This is the preferred API for series-block drag-to-viewer interactions. + */ + window[`loadExternalStack_${apiKey}`] = async (rawImageIds: string[], name?: string) => { + if (!Array.isArray(rawImageIds) || rawImageIds.length === 0) return; + await applyExternalDrop( + activeViewport ?? 0, + rawImageIds, + name || 'Series', + 'center', + ); + }; + + /** + * Load an external stack into a new split viewport. If the grid is already + * at its maximum size the stack is loaded into the active viewport instead. + */ + window[`loadExternalStackNewViewport_${apiKey}`] = async (rawImageIds: string[], name?: string) => { + if (!Array.isArray(rawImageIds) || rawImageIds.length === 0) return; + const vp = activeViewport ?? 0; + // Use 'right' as the default split direction; applyExternalDrop will fall + // back to center if the grid is already full. + await applyExternalDrop(vp, rawImageIds, name || 'Series', 'right'); + }; + // Export the current viewer state as JSON window[`exportViewerState_${apiKey}`] = () => { const numActive = viewportGrid.rows * viewportGrid.cols; @@ -3794,20 +3702,6 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer updateOverlay(activeViewport ?? 0, viewport); }; - window[`exportThumbDebug_${apiKey}`] = () => { - try { - return JSON.stringify(thumbDebugBufferRef.current, null, 2); - } catch (e) { - return '[]'; - } - }; - window[`clearThumbDebug_${apiKey}`] = () => { - thumbDebugBufferRef.current = []; - return true; - }; - - - // Cleanup return () => { window[`exportViewerState_${apiKey}`] = undefined; @@ -3822,10 +3716,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer window[`truncateStack_${apiKey}`] = undefined; window[`getCurrentStackPosition_${apiKey}`] = undefined; window[`loadAdditionalStack_${apiKey}`] = undefined; + window[`loadExternalStack_${apiKey}`] = undefined; + window[`loadExternalStackNewViewport_${apiKey}`] = undefined; window[`getViewport_${apiKey}`] = undefined; window[`getAllViewports_${apiKey}`] = undefined; - window[`exportThumbDebug_${apiKey}`] = undefined; - window[`clearThumbDebug_${apiKey}`] = undefined; }; }, [ viewportGrid, @@ -3951,15 +3845,20 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer }} onDoubleClick={() => handleDoubleClick(i)} onDragEnter={e => { - const idxStr = e.dataTransfer.getData('text/stack-idx'); - if (!idxStr) return; + const types = e.dataTransfer.types; + const hasInternal = types.includes('text/stack-idx'); + const hasExternal = types.includes('text/dv3d-imageids'); + if (!hasInternal && !hasExternal) return; e.preventDefault(); + if (hasExternal) setExternalDragActive(true); const zone = getDropZoneFromPointer(e); setDropPreview({ viewportIdx: i, zone }); }} onDragOver={e => { - const idxStr = e.dataTransfer.getData('text/stack-idx'); - if (!idxStr) return; + const types = e.dataTransfer.types; + const hasInternal = types.includes('text/stack-idx'); + const hasExternal = types.includes('text/dv3d-imageids'); + if (!hasInternal && !hasExternal) return; e.preventDefault(); const zone = getDropZoneFromPointer(e); const resolved = resolveDropZone(zone); @@ -3974,6 +3873,22 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer }} onDrop={e => { e.preventDefault(); + const externalImageIdsStr = e.dataTransfer.getData('text/dv3d-imageids'); + if (externalImageIdsStr) { + const stackName = e.dataTransfer.getData('text/dv3d-stack-name') || 'Series'; + try { + const rawIds: string[] = JSON.parse(externalImageIdsStr); + const zone = getDropZoneFromPointer(e); + applyExternalDrop(i, rawIds, stackName, zone).catch(err => { + console.error('Failed to apply external drop:', err); + }); + } catch (err) { + console.error('Invalid text/dv3d-imageids payload:', err); + } + setDropPreview(null); + setExternalDragActive(false); + return; + } const idxStr = e.dataTransfer.getData('text/stack-idx'); const stackIdx = parseInt(idxStr, 10); if (!isNaN(stackIdx)) { @@ -3984,9 +3899,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer } setDropPreview(null); setDraggedStackIdx(null); + setExternalDragActive(false); }} > - {draggedStackIdx !== null && currentPreviewZone && ( + {(draggedStackIdx !== null || externalDragActive) && currentPreviewZone && (
No Img
); } @@ -5651,31 +5557,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer src={src} alt={label} style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 4, background: '#111' }} - onLoad={(e) => { - const el = e.currentTarget; - thumbDebug('thumbnail-img-load', { - idx, - label, - src: truncateForDebug(src, 180), - naturalWidth: el.naturalWidth, - naturalHeight: el.naturalHeight, - clientWidth: el.clientWidth, - clientHeight: el.clientHeight, - }); - }} onError={() => { console.warn('Thumbnail failed to load for stack', idx, src); const usedGeneratedImage = !!generatedValid; - const likelyDicomFallback = !usedGeneratedImage && isLikelyDicomThumbnailSource(src); - thumbDebug('thumbnail-img-error', { - idx, - label, - src: truncateForDebug(src, 180), - usedGeneratedImage, - likelyDicomFallback, - requested: !!thumbRequestedRef.current[idx], - chosenImage: chosenImage ? truncateForDebug(chosenImage, 180) : null, - }); if (usedGeneratedImage) { // Generated image failed: mark as broken to avoid endless reload attempts. @@ -5686,10 +5570,6 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer // Fallback URL failed (often raw DICOM). Do not mark BROKEN yet; // request generation and keep the thumbnail pipeline alive. if (!thumbRequestedRef.current[idx]) { - thumbDebug('thumbnail-img-error-trigger-generate', { - idx, - chosenImage: chosenImage ? truncateForDebug(chosenImage, 180) : null, - }); generateThumbnail(idx, chosenImage as string | null); } }} diff --git a/dicom-viewer/src/main.tsx b/dicom-viewer/src/main.tsx index 41a6fc7..166c78c 100644 --- a/dicom-viewer/src/main.tsx +++ b/dicom-viewer/src/main.tsx @@ -1,10 +1,34 @@ import React from 'react' -import { createRoot } from "react-dom/client"; -import ReactDOM from 'react-dom/client' +import { createRoot, type Root } from "react-dom/client"; import App from './App.tsx' -import Nifti from './Nifti.tsx' import './index.css' -import { createImageIds, availableImageIds } from "./lib/createImageIds.ts" +import { availableImageIds } from "./lib/createImageIds.ts" + +type MountOptions = { + force?: boolean; + containerIds?: string[]; +}; + +type ViewerDescriptor = { + imageIds: string[]; + name?: string; + caseId?: string; + studyId?: string; +}; + +type RootRecord = { + root: Root; + signature: string; +}; + +const rootRegistry = new WeakMap(); + +declare global { + interface Window { + mountDicomViewers?: (options?: MountOptions) => void; + remountDicomViewer?: (containerId: string) => void; + } +} // Normalize incoming image ids/URLs and ensure DICOM entries use a valid wadouri: prefix. const normalizeImageId = (value: string) => { @@ -35,118 +59,137 @@ const toDescriptor = (arr: string[] | undefined) => { return { imageIds: toWadouri(arr) }; }; -export function mountDicomViewers() { - // Test containers - const test_containers = document.querySelectorAll(".dicom-viewer-test-root"); - test_containers.forEach((container) => { - const id = container.id || ''; - const imageStacks = () => availableImageIds(); - createRoot(container).render( - - ); - }); - - // Main containers - const containers = document.querySelectorAll(".dicom-viewer-root"); - containers.forEach((container) => { - const id = container.id || ''; - const mountPrefix = `[DV3D Mount][${id || 'default'}]`; - 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'); - const dataImages = container.getAttribute('data-images'); - - try { - console.debug(mountPrefix, 'container-attrs', { - path: window.location.pathname, - isEmpty, - hasDataNamedStacks: !!dataNamed, - hasDataImages: !!dataImages, - dataNamedLength: dataNamed ? dataNamed.length : 0, - dataImagesLength: dataImages ? dataImages.length : 0, - autoCacheStack: container.getAttribute('data-auto-cache-stack'), - }); - } catch (e) { - // ignore debug logging failures - } - - const parseToDescriptors = (parsed: any): Array => { - // Return array of descriptors: either simple string[] => { imageIds: string[] } - // or objects: { imageIds: string[], name?, caseId?, studyId? } +const parseToDescriptors = (parsed: any): Array => { if (!parsed) return []; - // If top-level is an array of strings -> single unnamed stack if (Array.isArray(parsed) && parsed.length > 0 && typeof parsed[0] === "string") { return [toDescriptor(parsed)]; } - if (Array.isArray(parsed)) { - const out: any[] = []; - parsed.forEach((item, itemIdx) => { - // Case object with nested stacks: { caseId, studyId, stacks: [ { name, imageIds } ] } - if (item && typeof item === "object" && Array.isArray(item.stacks)) { - const caseId = item.caseId || item.caseUID || item.case || undefined; - const studyId = item.studyId || item.studyUID || item.studyInstanceUID || undefined; - 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; + if (!Array.isArray(parsed)) { + return []; + } - // 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; - } + const out: Array = []; - out.push({ - imageIds: toWadouri(imageIds), - name: s.name || s.label || undefined, - caseId, - studyId: s.studyId || studyId, - }); - }); - return; - } + parsed.forEach((item, itemIdx) => { + if (item && typeof item === "object" && Array.isArray(item.stacks)) { + const caseId = item.caseId || item.caseUID || item.case || undefined; + const studyId = item.studyId || item.studyUID || item.studyInstanceUID || undefined; - // Stack object: { imageIds, i, name, caseId, studyId } - if (item && typeof item === 'object' && (Array.isArray(item.imageIds) || Array.isArray(item.i))) { - const imageIds = Array.isArray(item.imageIds) ? item.imageIds : item.i; + 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; 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); + console.warn(`data-named-stacks: skipping invalid nested stack at parsed[${itemIdx}].stacks[${sIdx}]`, s); return; } out.push({ imageIds: toWadouri(imageIds), - name: item.name || item.label || undefined, - caseId: item.caseId || item.caseUID || undefined, - studyId: item.studyId || item.studyUID || item.studyInstanceUID || undefined, + name: s.name || s.label || undefined, + caseId, + studyId: s.studyId || studyId, }); + }); + return; + } + + 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; } - // Plain array (stack) - if (Array.isArray(item) && item.length > 0 && typeof item[0] === "string") { - out.push(toDescriptor(item)); - return; - } + out.push({ + imageIds: toWadouri(imageIds), + name: item.name || item.label || undefined, + caseId: item.caseId || item.caseUID || undefined, + studyId: item.studyId || item.studyUID || item.studyInstanceUID || undefined, + }); + return; + } - // Unknown / invalid entry - console.warn(`data-named-stacks: unknown or invalid entry at parsed[${itemIdx}] — skipping`, item); - }); - return out; + if (Array.isArray(item) && item.length > 0 && typeof item[0] === "string") { + out.push(toDescriptor(item)); + return; + } + + console.warn(`data-named-stacks: unknown or invalid entry at parsed[${itemIdx}] — skipping`, item); + }); + + return out; +}; + +const getMountSignature = (container: HTMLElement): string => { + return JSON.stringify({ + id: container.id || '', + empty: container.getAttribute('data-empty') || '', + namedStacks: container.getAttribute('data-named-stacks') || '', + images: container.getAttribute('data-images') || '', + autoCache: container.getAttribute('data-auto-cache-stack') || '', + annotation: container.getAttribute('data-annotationjson') || '', + viewerState: container.getAttribute('data-viewerstate') || '', + }); +}; + +const shouldMountContainer = (container: HTMLElement, options?: MountOptions): boolean => { + if (!options?.containerIds || options.containerIds.length === 0) return true; + return options.containerIds.includes(container.id || ''); +}; + +const mountContainer = (container: HTMLElement, imageStacks: () => Promise, options?: MountOptions) => { + const signature = getMountSignature(container); + const existing = rootRegistry.get(container); + + if (existing && !options?.force && existing.signature === signature) { + return; } - return []; + if (existing) { + existing.root.unmount(); + rootRegistry.delete(container); + } + + const root = createRoot(container); + root.render( + + ); + + rootRegistry.set(container, { root, signature }); }; +export function mountDicomViewers(options?: MountOptions) { + // Test containers + const test_containers = document.querySelectorAll(".dicom-viewer-test-root"); + test_containers.forEach((container) => { + if (!shouldMountContainer(container as HTMLElement, options)) return; + const id = container.id || ''; + const imageStacks = () => availableImageIds(); + mountContainer(container as HTMLElement, imageStacks, options); + }); + + // Main containers + const containers = document.querySelectorAll(".dicom-viewer-root"); + containers.forEach((container) => { + if (!shouldMountContainer(container as HTMLElement, options)) return; + + let imageStacks: () => Promise; + const isEmpty = container.getAttribute('data-empty') === 'true'; + + const dataNamed = container.getAttribute('data-named-stacks'); + const dataImages = container.getAttribute('data-images'); + if (isEmpty) { imageStacks = () => Promise.resolve([] as any[]); } else if (dataNamed) { @@ -158,25 +201,6 @@ export function mountDicomViewers() { parsed = []; } const descriptors = parseToDescriptors(parsed); - try { - console.debug(mountPrefix, 'parsed-data-named-stacks', { - descriptorCount: descriptors.length, - summary: descriptors.map((d, idx) => { - const imageIds = Array.isArray(d.imageIds) ? d.imageIds : []; - const sample = imageIds[Math.floor(imageIds.length / 2)] || imageIds[0] || ''; - return { - idx, - name: d.name || null, - caseId: d.caseId || null, - studyId: d.studyId || null, - imageCount: imageIds.length, - sampleImageId: sample.length > 180 ? `${sample.slice(0, 180)}...(${sample.length} chars)` : sample, - }; - }), - }); - } catch (e) { - // ignore debug logging failures - } imageStacks = () => Promise.resolve(descriptors); } else if (dataImages) { let parsed; @@ -186,15 +210,6 @@ export function mountDicomViewers() { console.error("Invalid data-images JSON:", e); parsed = []; } - try { - console.debug(mountPrefix, 'parsed-data-images', { - topLevelIsArray: Array.isArray(parsed), - topLevelLength: Array.isArray(parsed) ? parsed.length : 0, - }); - } catch (e) { - // ignore debug logging failures - } - // Keep old behavior for data-images if (Array.isArray(parsed) && parsed.length > 0 && typeof parsed[0] === "string") { imageStacks = () => Promise.resolve([toWadouri(parsed)]); } else if (Array.isArray(parsed)) { @@ -205,43 +220,18 @@ export function mountDicomViewers() { imageStacks = () => Promise.resolve([[]]); } } else { - // 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 autoCacheStack = autoCacheStackAttr === "true" || autoCacheStackAttr === "1"; - - const annotationJson = container.getAttribute('data-annotationjson'); - const viewerState = container.getAttribute('data-viewerstate'); - console.log("Mounting DICOM viewer:") - console.log(viewerState, annotationJson, autoCacheStack); - try { - console.debug(mountPrefix, 'mount-props', { - hasViewerState: !!viewerState, - viewerStateLength: viewerState ? viewerState.length : 0, - hasAnnotationJson: !!annotationJson, - annotationJsonLength: annotationJson ? annotationJson.length : 0, - autoCacheStack, - }); - } catch (e) { - // ignore debug logging failures - } - - createRoot(container).render( - - ); + mountContainer(container as HTMLElement, imageStacks, options); }); } // Optionally, call it immediately for static containers: mountDicomViewers(); -// Optionally, expose globally for dynamic use: -(window as any).mountDicomViewers = mountDicomViewers; \ No newline at end of file +// Expose globally for dynamic use. +(window as any).mountDicomViewers = mountDicomViewers; +(window as any).remountDicomViewer = (containerId: string) => { + mountDicomViewers({ force: true, containerIds: [containerId] }); +}; \ No newline at end of file