Refactor stack thumbnail management with dedicated setter and localStorage integration for stack panel width

This commit is contained in:
Ross
2026-05-18 12:33:28 +01:00
parent c9902a2e15
commit 7b786fcf9f
+94 -10
View File
@@ -218,6 +218,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
// Right-side stack panel open state (toggled by button) // Right-side stack panel open state (toggled by button)
const [stackPanelOpen, setStackPanelOpen] = useState(false); const [stackPanelOpen, setStackPanelOpen] = useState(false);
const [stackPanelWidth, setStackPanelWidth] = useState(320); const [stackPanelWidth, setStackPanelWidth] = useState(320);
const STACK_PANEL_WIDTH_KEY = `dv3d_stackPanelWidth_${container_id || 'default'}`;
const stackPanelResizeRef = useRef<{ resizing: boolean; startX: number; startWidth: number }>({ const stackPanelResizeRef = useRef<{ resizing: boolean; startX: number; startWidth: number }>({
resizing: false, resizing: false,
startX: 0, startX: 0,
@@ -278,8 +279,65 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
// Client-side generated thumbnails cache: stackIndex -> dataURL // Client-side generated thumbnails cache: stackIndex -> dataURL
const [stackThumbnails, setStackThumbnails] = useState<Record<number, string>>({}); const [stackThumbnails, setStackThumbnails] = useState<Record<number, string>>({});
const stackThumbnailsRef = useRef<Record<number, string>>({});
const thumbRequestedRef = useRef<Record<number, boolean>>({}); const thumbRequestedRef = useRef<Record<number, boolean>>({});
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) // Helper to find a canvas element under a given node (recursive)
function findCanvasIn(node: ParentNode | null): HTMLCanvasElement | null { function findCanvasIn(node: ParentNode | null): HTMLCanvasElement | null {
if (!node) return null; if (!node) return null;
@@ -769,13 +827,13 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
ctx.fillRect(0, 0, size, size); ctx.fillRect(0, 0, size, size);
ctx.drawImage(img, x, y, w, h); ctx.drawImage(img, x, y, w, h);
const dataUrl = canvas.toDataURL('image/png'); const dataUrl = canvas.toDataURL('image/png');
setStackThumbnails(prev => ({ ...prev, [stackIdx]: dataUrl })); setStackThumbnail(stackIdx, dataUrl);
} else { } else {
setStackThumbnails(prev => ({ ...prev, [stackIdx]: 'BROKEN' })); setStackThumbnail(stackIdx, 'BROKEN');
} }
} catch (err) { } catch (err) {
console.warn('generateThumbnail: failed to generate thumbnail for external image', err); console.warn('generateThumbnail: failed to generate thumbnail for external image', err);
setStackThumbnails(prev => ({ ...prev, [stackIdx]: 'BROKEN' })); setStackThumbnail(stackIdx, 'BROKEN');
} }
return; return;
} }
@@ -868,30 +926,46 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
if (canvas) { if (canvas) {
try { try {
const dataUrl = canvas.toDataURL('image/png'); if (typeof canvas.toBlob === 'function') {
setStackThumbnails(prev => ({ ...prev, [stackIdx]: dataUrl })); const blobUrl = await new Promise<string | null>((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); console.debug('generateThumbnail: thumbnail captured for stack', stackIdx);
} catch (err) { } catch (err) {
console.warn('generateThumbnail: toDataURL failed', err); console.warn('generateThumbnail: toDataURL failed', err);
// Mark as broken so UI shows placeholder instead of attempting to load raw DICOM URL // Mark as broken so UI shows placeholder instead of attempting to load raw DICOM URL
setStackThumbnails(prev => ({ ...prev, [stackIdx]: 'BROKEN' })); setStackThumbnail(stackIdx, 'BROKEN');
} }
} else { } else {
console.warn('generateThumbnail: no canvas found after render for', viewportId, 'canvasReady=', canvasReady, 'vp.element=', !!(vp && vp.element)); 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 <img> src // Mark as broken so we don't attempt to use raw .dcm URL as <img> src
setStackThumbnails(prev => ({ ...prev, [stackIdx]: 'BROKEN' })); setStackThumbnail(stackIdx, 'BROKEN');
} }
} catch (err) { } catch (err) {
// log and mark broken so UI shows placeholder // log and mark broken so UI shows placeholder
console.warn('Thumbnail generation failed for', chosenImageId, err); 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 { } finally {
try { renderingEngine.disableElement(viewportId); } catch (e) { try { renderingEngine.disableElement(viewportId); } catch (e) {
console.warn('Failed to disable thumbnail element:', e); console.warn('Failed to disable thumbnail element:', e);
} }
if (el && el.parentNode) el.parentNode.removeChild(el); if (el && el.parentNode) el.parentNode.removeChild(el);
} }
}, [container_id, stackThumbnails]); }, [container_id, stackThumbnails, setStackThumbnail]);
// Kick off thumbnail generation when stacks change // Kick off thumbnail generation when stacks change
useEffect(() => { useEffect(() => {
@@ -2775,6 +2849,16 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
setViewportImageIds(Array(MAX_VIEWPORTS).fill([])); setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
setSelectedStackIdx(Array(MAX_VIEWPORTS).fill(0)); setSelectedStackIdx(Array(MAX_VIEWPORTS).fill(0));
setViewportModes(Array(MAX_VIEWPORTS).fill('stack')); 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({}); setStackThumbnails({});
thumbRequestedRef.current = {}; thumbRequestedRef.current = {};
setStackOrderModified(false); setStackOrderModified(false);
@@ -5215,7 +5299,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
onError={(e) => { onError={(e) => {
console.warn('Thumbnail <img> failed to load for stack', idx, src); console.warn('Thumbnail <img> failed to load for stack', idx, src);
// Mark thumbnail as broken so we render the placeholder instead // Mark thumbnail as broken so we render the placeholder instead
setStackThumbnails(prev => ({ ...prev, [idx]: 'BROKEN' })); setStackThumbnail(idx, 'BROKEN');
}} }}
/> />
); );