diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index d082465..12aed99 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -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 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:' + 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);