From 181f471806cd157ad9984430fdd9ac5612c2dfc2 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 15 Dec 2025 09:07:36 +0000 Subject: [PATCH] basic --- dicom-viewer/src/App.tsx | 162 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index 72bd919..d082465 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -264,6 +264,157 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer return false; } + // 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) { + let scale = 1; + let translateX = 0; + let translateY = 0; + let isPanning = false; + let startX = 0; + let startY = 0; + let lastClientX = 0; + let lastClientY = 0; + + img.style.transformOrigin = '0 0'; + img.style.willChange = 'transform'; + img.style.cursor = 'grab'; + + const clamp = (v: number, a = 0.1, b = 10) => Math.min(b, Math.max(a, v)); + + const apply = () => { + img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`; + }; + + const onWheel = (e: WheelEvent) => { + // Zoom on wheel + e.preventDefault(); + const rect = img.getBoundingClientRect(); + const offsetX = e.clientX - rect.left; + const offsetY = e.clientY - rect.top; + const delta = -e.deltaY; + const zoomFactor = 1 + (delta > 0 ? 0.1 : -0.1) * Math.min(4, Math.abs(delta) / 100); + const newScale = clamp(scale * zoomFactor, 0.1, 10); + + // Keep point under cursor stationary + translateX = (translateX - offsetX) * (newScale / scale) + offsetX; + translateY = (translateY - offsetY) * (newScale / scale) + offsetY; + scale = newScale; + apply(); + }; + + const onMouseDown = (e: MouseEvent) => { + if (e.button !== 0) return; + isPanning = true; + startX = translateX; + startY = translateY; + lastClientX = e.clientX; + lastClientY = e.clientY; + img.style.cursor = 'grabbing'; + e.preventDefault(); + }; + + const onMouseMove = (e: MouseEvent) => { + if (!isPanning) return; + const dx = e.clientX - lastClientX; + const dy = e.clientY - lastClientY; + translateX += dx; + translateY += dy; + lastClientX = e.clientX; + lastClientY = e.clientY; + apply(); + }; + + const onMouseUp = (e: MouseEvent) => { + if (!isPanning) return; + isPanning = false; + img.style.cursor = 'grab'; + }; + + const onDblClick = (e: MouseEvent) => { + // reset + scale = 1; + translateX = 0; + translateY = 0; + apply(); + }; + + // Touch support: simple panning (single touch) and pinch-to-zoom (two touches) + let lastTouchDist = 0; + const getTouchDist = (t1: Touch, t2: Touch) => Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY); + + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length === 1) { + 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]); + } + }; + + const onTouchMove = (e: TouchEvent) => { + if (e.touches.length === 1 && isPanning) { + const dx = e.touches[0].clientX - lastClientX; + const dy = e.touches[0].clientY - lastClientY; + translateX += dx; + translateY += dy; + lastClientX = e.touches[0].clientX; + lastClientY = e.touches[0].clientY; + apply(); + } else if (e.touches.length === 2) { + const curDist = getTouchDist(e.touches[0], e.touches[1]); + if (lastTouchDist > 0) { + const zoomFactor = curDist / lastTouchDist; + const rect = img.getBoundingClientRect(); + const centerX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left; + const centerY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top; + const newScale = clamp(scale * zoomFactor, 0.1, 10); + translateX = (translateX - centerX) * (newScale / scale) + centerX; + translateY = (translateY - centerY) * (newScale / scale) + centerY; + scale = newScale; + apply(); + } + lastTouchDist = curDist; + } + e.preventDefault(); + }; + + const onTouchEnd = (e: TouchEvent) => { + if (e.touches.length === 0) { + isPanning = false; + lastTouchDist = 0; + } + }; + + // Attach listeners to the container so we get events even if the img is not full-size + container.addEventListener('wheel', onWheel, { passive: false }); + container.addEventListener('mousedown', onMouseDown as any); + window.addEventListener('mousemove', onMouseMove as any); + window.addEventListener('mouseup', onMouseUp as any); + img.addEventListener('dblclick', onDblClick as any); + container.addEventListener('touchstart', onTouchStart as any, { passive: false }); + container.addEventListener('touchmove', onTouchMove as any, { passive: false }); + container.addEventListener('touchend', onTouchEnd as any); + + const cleanup = () => { + container.removeEventListener('wheel', onWheel as any); + container.removeEventListener('mousedown', onMouseDown as any); + window.removeEventListener('mousemove', onMouseMove as any); + window.removeEventListener('mouseup', onMouseUp as any); + img.removeEventListener('dblclick', onDblClick as any); + container.removeEventListener('touchstart', onTouchStart as any); + container.removeEventListener('touchmove', onTouchMove as any); + container.removeEventListener('touchend', onTouchEnd as any); + // reset transform + img.style.transform = ''; + img.style.cursor = ''; + img.style.willChange = ''; + }; + + return cleanup; + } + const [stackOrderModified, setStackOrderModified] = useState(false); const [loadingState, setLoadingState] = useState(null); // Load annotations and viewer state from props if provided @@ -1274,6 +1425,12 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const firstId = imageIds[0]; if (isExternalImageId(firstId)) { try { + // Cleanup previous external handlers if any + try { + const prev = (element as any)._externalPanZoom; + if (prev && typeof prev.cleanup === 'function') prev.cleanup(); + } catch (e) { /* ignore */ } + // Clear existing content and insert an image element element.innerHTML = ''; const img = document.createElement('img'); @@ -1285,6 +1442,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer // 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 }; + // Do not attach DICOM-specific tools to external images mainToolGroup.removeViewports(renderingEngineId, viewportId); } catch (err) {