Add support for external image loading and enhance pan/zoom interactions

This commit is contained in:
Ross
2025-12-15 09:15:27 +00:00
parent 181f471806
commit 72462b49ef
+100 -13
View File
@@ -2,8 +2,10 @@ import { useEffect, useRef, useCallback, useState } from "react"
import {
RenderingEngine,
Enums,
imageLoader,
utilities as csUtilities,
} from "@cornerstonejs/core"
import { init as csRenderInit, utilities as csUtilities } from "@cornerstonejs/core"
import { init as csRenderInit } from "@cornerstonejs/core"
import { init as csToolsInit, addTool, ToolGroupManager, Enums as csToolsEnums, PanTool, WindowLevelTool, StackScrollTool, ZoomTool, PlanarRotateTool, utilities as csToolsUtilities } from "@cornerstonejs/tools"
import { LengthTool, ProbeTool, ArrowAnnotateTool, RectangleROITool, EllipticalROITool, PlanarFreehandROITool, PlanarFreehandContourSegmentationTool, SculptorTool, CrosshairsTool, ReferenceLinesTool, ReferenceCursors } from "@cornerstonejs/tools"
import { init as dicomImageLoaderInit } from "@cornerstonejs/dicom-image-loader"
@@ -257,6 +259,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
if (!imageId) return false;
// Treat wadouri: as DICOM (our existing loader)
if (imageId.startsWith('wadouri:')) return false;
if (imageId.startsWith('external:')) return true;
// blob: or data:image/ or common extensions
if (imageId.startsWith('blob:') || imageId.startsWith('data:image/')) return true;
const lower = imageId.toLowerCase();
@@ -266,7 +269,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
// Enable simple pan & zoom interactions for an <img> placed inside a viewport element.
// Attaches event listeners and returns a cleanup function.
function enableImagePanZoom(container: HTMLElement, img: HTMLImageElement) {
function enableImagePanZoom(container: HTMLElement, img: HTMLImageElement, options?: { allowPan?: boolean, allowZoom?: boolean }) {
const allowPanInit = options?.allowPan ?? true;
const allowZoomInit = options?.allowZoom ?? true;
let scale = 1;
let translateX = 0;
let translateY = 0;
@@ -286,7 +291,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
};
let allowPan = allowPanInit;
let allowZoom = allowZoomInit;
const onWheel = (e: WheelEvent) => {
if (!allowZoom) return;
// Zoom on wheel
e.preventDefault();
const rect = img.getBoundingClientRect();
@@ -305,6 +314,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return;
if (!allowPan) return;
isPanning = true;
startX = translateX;
startY = translateY;
@@ -345,9 +355,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length === 1) {
isPanning = true;
lastClientX = e.touches[0].clientX;
lastClientY = e.touches[0].clientY;
if (allowPan) {
isPanning = true;
lastClientX = e.touches[0].clientX;
lastClientY = e.touches[0].clientY;
}
} else if (e.touches.length === 2) {
lastTouchDist = getTouchDist(e.touches[0], e.touches[1]);
}
@@ -362,7 +374,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
lastClientX = e.touches[0].clientX;
lastClientY = e.touches[0].clientY;
apply();
} else if (e.touches.length === 2) {
} else if (e.touches.length === 2 && allowZoom) {
const curDist = getTouchDist(e.touches[0], e.touches[1]);
if (lastTouchDist > 0) {
const zoomFactor = curDist / lastTouchDist;
@@ -412,7 +424,12 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
img.style.willChange = '';
};
return cleanup;
// Return control functions so we can update allowed interactions later
return {
cleanup,
setAllowPan: (v: boolean) => { allowPan = v; },
setAllowZoom: (v: boolean) => { allowZoom = v; },
};
}
const [stackOrderModified, setStackOrderModified] = useState(false);
@@ -614,7 +631,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
try {
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = chosenImageId as string;
const src = (chosenImageId as string).replace(/^external:/, '');
img.src = src;
await new Promise((resolve, reject) => {
img.onload = () => resolve(true);
img.onerror = (e) => reject(e);
@@ -1144,6 +1162,28 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
toolGroup.setToolActive(tool, { bindings });
});
// Also update external image handlers (pan/zoom) to respect selected tools
try {
const primaryTool = mouseToolBindings.Primary;
const wheelTool = mouseToolBindings.Wheel;
const auxTool = mouseToolBindings.Auxiliary;
// Determine whether pan/zoom should be enabled for primary/wheel interactions
const allowPan = primaryTool === PanTool.toolName || auxTool === PanTool.toolName;
const allowZoom = primaryTool === ZoomTool.toolName || wheelTool === ZoomTool.toolName || mouseToolBindings.Primary_and_Secondary === ZoomTool.toolName;
// Iterate viewports and update external handlers
viewportRefs.current.forEach((el) => {
if (!el) return;
const ext = (el as any)._externalPanZoom;
if (ext) {
if (typeof ext.setAllowPan === 'function') ext.setAllowPan(!!allowPan);
if (typeof ext.setAllowZoom === 'function') ext.setAllowZoom(!!allowZoom);
}
});
} catch (e) {
// ignore
}
//// Always re-apply the mouse wheel binding for StackScrollTool
//toolGroup.setToolActive(StackScrollTool.toolName, {
// bindings: [
@@ -1187,11 +1227,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
}
}
// Create imageIds: use wadouri: for DICOM files, direct blob URLs for image files
// Create imageIds: use wadouri: for DICOM files, `external:` for image files
const imageIds = fileArray.map((file) => {
const isImage = file.type && file.type.startsWith('image/');
if (isImage) {
return URL.createObjectURL(file);
return `external:${URL.createObjectURL(file)}`;
}
return `wadouri:${URL.createObjectURL(file)}`;
});
@@ -1321,6 +1361,53 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
await csToolsInit(
);
dicomImageLoaderInit();
// Register client-side image loader for external images (JPEG/PNG/blob/data)
try {
imageLoader.registerImageLoader('external', async (imageId: string) => {
// imageId expected as 'external:<url>'
const url = imageId.replace(/^external:/, '');
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = url;
await new Promise((resolve, reject) => {
img.onload = () => resolve(true);
img.onerror = (e) => reject(e);
});
const w = img.naturalWidth || img.width;
const h = img.naturalHeight || img.height;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Unable to create canvas context');
ctx.drawImage(img, 0, 0, w, h);
const im = ctx.getImageData(0, 0, w, h);
const image = {
imageId,
minPixelValue: 0,
maxPixelValue: 255,
slope: 1.0,
intercept: 0,
rows: h,
columns: w,
height: h,
width: w,
color: true,
rgba: true,
sizeInBytes: im.data.length,
getPixelData: () => im.data,
// Some Cornerstone APIs expect `getImageData` or `getCanvas` - provide canvas and ImageData
getImageData: () => im,
getCanvas: () => canvas,
} as any;
return image;
});
} catch (e) {
console.warn('Failed to register external image loader', e);
}
}
@@ -1438,14 +1525,14 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
img.style.height = '100%';
img.style.objectFit = 'contain';
img.style.display = 'block';
img.src = firstId as string;
img.src = (firstId as string).replace(/^external:/, '');
// allow drag to open in new tab etc.
img.setAttribute('draggable', 'false');
element.appendChild(img);
// Attach pan/zoom interactions to this external image
const cleanup = enableImagePanZoom(element, img);
(element as any)._externalPanZoom = { cleanup };
const ext = enableImagePanZoom(element, img);
(element as any)._externalPanZoom = ext;
// Do not attach DICOM-specific tools to external images
mainToolGroup.removeViewports(renderingEngineId, viewportId);