This commit is contained in:
Ross
2025-12-15 09:07:36 +00:00
parent 8a0a08000e
commit 181f471806
+162
View File
@@ -264,6 +264,157 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
return false;
}
// 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) {
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 | "annotations" | "viewerState">(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) {