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)
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<Record<number, string>>({});
const stackThumbnailsRef = useRef<Record<number, string>>({});
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)
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<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);
} 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 <img> 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 <img> 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');
}}
/>
);