From 7b786fcf9fee42e778d5b917582737f014baeb90 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 18 May 2026 12:33:28 +0100 Subject: [PATCH] Refactor stack thumbnail management with dedicated setter and localStorage integration for stack panel width --- dicom-viewer/src/App.tsx | 104 +++++++++++++++++++++++++++++++++++---- 1 file changed, 94 insertions(+), 10 deletions(-) diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index 90f82fa..5d90324 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -218,6 +218,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer // Right-side stack panel open state (toggled by button) const [stackPanelOpen, setStackPanelOpen] = useState(false); const [stackPanelWidth, setStackPanelWidth] = useState(320); + const STACK_PANEL_WIDTH_KEY = `dv3d_stackPanelWidth_${container_id || 'default'}`; const stackPanelResizeRef = useRef<{ resizing: boolean; startX: number; startWidth: number }>({ resizing: false, startX: 0, @@ -278,8 +279,65 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer // Client-side generated thumbnails cache: stackIndex -> dataURL const [stackThumbnails, setStackThumbnails] = useState>({}); + const stackThumbnailsRef = useRef>({}); const thumbRequestedRef = useRef>({}); + const setStackThumbnail = useCallback((stackIdx: number, value: string) => { + setStackThumbnails(prev => { + const previous = prev[stackIdx]; + if (previous && previous !== value && previous.startsWith('blob:')) { + try { + URL.revokeObjectURL(previous); + } catch (e) { + // ignore revoke failures + } + } + const next = { ...prev, [stackIdx]: value }; + stackThumbnailsRef.current = next; + return next; + }); + }, []); + + useEffect(() => { + stackThumbnailsRef.current = stackThumbnails; + }, [stackThumbnails]); + + useEffect(() => { + return () => { + Object.values(stackThumbnailsRef.current).forEach((thumbnail) => { + if (typeof thumbnail === 'string' && thumbnail.startsWith('blob:')) { + try { + URL.revokeObjectURL(thumbnail); + } catch (e) { + // ignore revoke failures + } + } + }); + }; + }, []); + + useEffect(() => { + try { + const saved = localStorage.getItem(STACK_PANEL_WIDTH_KEY); + if (saved) { + const parsed = Number(saved); + if (!Number.isNaN(parsed)) { + setStackPanelWidth(Math.max(STACK_PANEL_MIN_WIDTH, Math.min(STACK_PANEL_MAX_WIDTH, Math.round(parsed)))); + } + } + } catch (e) { + // ignore storage access errors + } + }, [STACK_PANEL_WIDTH_KEY]); + + useEffect(() => { + try { + localStorage.setItem(STACK_PANEL_WIDTH_KEY, String(Math.round(stackPanelWidth))); + } catch (e) { + // ignore storage access errors + } + }, [STACK_PANEL_WIDTH_KEY, stackPanelWidth]); + // Helper to find a canvas element under a given node (recursive) function findCanvasIn(node: ParentNode | null): HTMLCanvasElement | null { if (!node) return null; @@ -769,13 +827,13 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer ctx.fillRect(0, 0, size, size); ctx.drawImage(img, x, y, w, h); const dataUrl = canvas.toDataURL('image/png'); - setStackThumbnails(prev => ({ ...prev, [stackIdx]: dataUrl })); + setStackThumbnail(stackIdx, dataUrl); } else { - setStackThumbnails(prev => ({ ...prev, [stackIdx]: 'BROKEN' })); + setStackThumbnail(stackIdx, 'BROKEN'); } } catch (err) { console.warn('generateThumbnail: failed to generate thumbnail for external image', err); - setStackThumbnails(prev => ({ ...prev, [stackIdx]: 'BROKEN' })); + setStackThumbnail(stackIdx, 'BROKEN'); } return; } @@ -868,30 +926,46 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer if (canvas) { try { - const dataUrl = canvas.toDataURL('image/png'); - setStackThumbnails(prev => ({ ...prev, [stackIdx]: dataUrl })); + if (typeof canvas.toBlob === 'function') { + const blobUrl = await new Promise((resolve) => { + canvas.toBlob((blob) => { + if (!blob) { + resolve(null); + return; + } + resolve(URL.createObjectURL(blob)); + }, 'image/png'); + }); + if (blobUrl) { + setStackThumbnail(stackIdx, blobUrl); + } else { + setStackThumbnail(stackIdx, canvas.toDataURL('image/png')); + } + } else { + setStackThumbnail(stackIdx, canvas.toDataURL('image/png')); + } console.debug('generateThumbnail: thumbnail captured for stack', stackIdx); } catch (err) { console.warn('generateThumbnail: toDataURL failed', err); // Mark as broken so UI shows placeholder instead of attempting to load raw DICOM URL - setStackThumbnails(prev => ({ ...prev, [stackIdx]: 'BROKEN' })); + setStackThumbnail(stackIdx, 'BROKEN'); } } else { console.warn('generateThumbnail: no canvas found after render for', viewportId, 'canvasReady=', canvasReady, 'vp.element=', !!(vp && vp.element)); // Mark as broken so we don't attempt to use raw .dcm URL as src - setStackThumbnails(prev => ({ ...prev, [stackIdx]: 'BROKEN' })); + setStackThumbnail(stackIdx, 'BROKEN'); } } catch (err) { // log and mark broken so UI shows placeholder console.warn('Thumbnail generation failed for', chosenImageId, err); - try { setStackThumbnails(prev => ({ ...prev, [stackIdx]: 'BROKEN' })); } catch (e) { /* ignore */ } + try { setStackThumbnail(stackIdx, 'BROKEN'); } catch (e) { /* ignore */ } } finally { try { renderingEngine.disableElement(viewportId); } catch (e) { console.warn('Failed to disable thumbnail element:', e); } if (el && el.parentNode) el.parentNode.removeChild(el); } - }, [container_id, stackThumbnails]); + }, [container_id, stackThumbnails, setStackThumbnail]); // Kick off thumbnail generation when stacks change useEffect(() => { @@ -2775,6 +2849,16 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer setViewportImageIds(Array(MAX_VIEWPORTS).fill([])); setSelectedStackIdx(Array(MAX_VIEWPORTS).fill(0)); setViewportModes(Array(MAX_VIEWPORTS).fill('stack')); + Object.values(stackThumbnailsRef.current).forEach((thumbnail) => { + if (typeof thumbnail === 'string' && thumbnail.startsWith('blob:')) { + try { + URL.revokeObjectURL(thumbnail); + } catch (e) { + // ignore revoke failures + } + } + }); + stackThumbnailsRef.current = {}; setStackThumbnails({}); thumbRequestedRef.current = {}; setStackOrderModified(false); @@ -5215,7 +5299,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer onError={(e) => { console.warn('Thumbnail failed to load for stack', idx, src); // Mark thumbnail as broken so we render the placeholder instead - setStackThumbnails(prev => ({ ...prev, [idx]: 'BROKEN' })); + setStackThumbnail(idx, 'BROKEN'); }} /> );