Add support for external image loading and enhance pan/zoom interactions
This commit is contained in:
+100
-13
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user