Refactor stack thumbnail management with dedicated setter and localStorage integration for stack panel width
This commit is contained in:
+94
-10
@@ -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');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user