imrove multiuser sync support

This commit is contained in:
Ross
2026-06-29 09:38:26 +01:00
parent 0c4da6e022
commit f323dfd559
2 changed files with 107 additions and 73 deletions
+93 -72
View File
@@ -527,6 +527,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
const webSocketRef = useRef<WebSocket | null>(null); const webSocketRef = useRef<WebSocket | null>(null);
const logToPage = useCallback((msg: string, data?: any) => {
if (typeof (window as any).logDebug === "function") {
(window as any).logDebug(msg, data);
}
}, []);
const wsStateRef = useRef({ const wsStateRef = useRef({
connected: false, connected: false,
isController: false, isController: false,
@@ -597,6 +603,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
const content = JSON.parse(messageEvent.data); const content = JSON.parse(messageEvent.data);
const type = content.type; const type = content.type;
appLogger.info("Viewer WS event received (type=" + type + "):", content); appLogger.info("Viewer WS event received (type=" + type + "):", content);
logToPage(`[Viewer WS] Received message (type=${type})`, content);
if (type === "welcome") { if (type === "welcome") {
setMyChannelName(content.channel_name); setMyChannelName(content.channel_name);
@@ -641,7 +648,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
setWsError(`Error initializing connection: ${e.message}`); setWsError(`Error initializing connection: ${e.message}`);
setWsConnecting(false); setWsConnecting(false);
} }
}, [container_id]); }, [container_id, logToPage]);
const disconnectMultiuser = useCallback(() => { const disconnectMultiuser = useCallback(() => {
appLogger.info("Disconnecting viewer WS connection"); appLogger.info("Disconnecting viewer WS connection");
@@ -661,35 +668,43 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
const requestControl = useCallback(() => { const requestControl = useCallback(() => {
if (webSocketRef.current && wsConnected) { if (webSocketRef.current && wsConnected) {
appLogger.info("Sending request_control via WS"); appLogger.info("Sending request_control via WS");
webSocketRef.current.send(JSON.stringify({ type: "request_control" })); const msg = { type: "request_control" };
webSocketRef.current.send(JSON.stringify(msg));
logToPage("[Viewer WS] Sent message (type=request_control)", msg);
} }
}, [wsConnected]); }, [wsConnected, logToPage]);
const takeControl = useCallback(() => { const takeControl = useCallback(() => {
if (webSocketRef.current && wsConnected && isStaff) { if (webSocketRef.current && wsConnected && isStaff) {
appLogger.info("Sending take_control via WS"); appLogger.info("Sending take_control via WS");
webSocketRef.current.send(JSON.stringify({ type: "take_control" })); const msg = { type: "take_control" };
webSocketRef.current.send(JSON.stringify(msg));
logToPage("[Viewer WS] Sent message (type=take_control)", msg);
} }
}, [wsConnected, isStaff]); }, [wsConnected, isStaff, logToPage]);
const grantControl = useCallback((targetChannel: string, targetUsername: string) => { const grantControl = useCallback((targetChannel: string, targetUsername: string) => {
if (webSocketRef.current && wsConnected) { if (webSocketRef.current && wsConnected) {
appLogger.info("Sending grant_control via WS to:", targetUsername, targetChannel); appLogger.info("Sending grant_control via WS to:", targetUsername, targetChannel);
webSocketRef.current.send(JSON.stringify({ const msg = {
type: "grant_control", type: "grant_control",
target_channel: targetChannel, target_channel: targetChannel,
target_username: targetUsername target_username: targetUsername
})); };
webSocketRef.current.send(JSON.stringify(msg));
logToPage("[Viewer WS] Sent message (type=grant_control)", msg);
setControlRequestNotification(null); setControlRequestNotification(null);
} }
}, [wsConnected]); }, [wsConnected, logToPage]);
const releaseControl = useCallback(() => { const releaseControl = useCallback(() => {
if (webSocketRef.current && wsConnected) { if (webSocketRef.current && wsConnected) {
appLogger.info("Sending release_control via WS"); appLogger.info("Sending release_control via WS");
webSocketRef.current.send(JSON.stringify({ type: "release_control" })); const msg = { type: "release_control" };
webSocketRef.current.send(JSON.stringify(msg));
logToPage("[Viewer WS] Sent message (type=release_control)", msg);
} }
}, [wsConnected]); }, [wsConnected, logToPage]);
useEffect(() => { useEffect(() => {
if (multiuserRoom) { if (multiuserRoom) {
@@ -723,10 +738,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
lastStateSentRef.current = jsonState; lastStateSentRef.current = jsonState;
if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) { if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) {
appLogger.info("Sending viewer_state via WS:", jsonState); appLogger.info("Sending viewer_state via WS:", jsonState);
webSocketRef.current.send(JSON.stringify({ const msg = {
type: "viewer_state", type: "viewer_state",
data: jsonState data: jsonState
})); };
webSocketRef.current.send(JSON.stringify(msg));
logToPage("[Viewer WS] Sent message (type=viewer_state)", msg);
} }
} }
} catch (err) { } catch (err) {
@@ -734,7 +751,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
} }
} }
}, 120); }, 120);
}, [container_id]); }, [container_id, logToPage]);
// Throttled annotations send // Throttled annotations send
const lastAnnotationsSentRef = useRef<string>(""); const lastAnnotationsSentRef = useRef<string>("");
@@ -757,10 +774,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
lastAnnotationsSentRef.current = jsonAnn; lastAnnotationsSentRef.current = jsonAnn;
if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) { if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) {
appLogger.info("Sending annotations via WS:", jsonAnn); appLogger.info("Sending annotations via WS:", jsonAnn);
webSocketRef.current.send(JSON.stringify({ const msg = {
type: "annotations", type: "annotations",
data: jsonAnn data: jsonAnn
})); };
webSocketRef.current.send(JSON.stringify(msg));
logToPage("[Viewer WS] Sent message (type=annotations)", msg);
} }
} }
} catch (err) { } catch (err) {
@@ -768,48 +787,8 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
} }
} }
}, 300); }, 300);
}, [container_id]); }, [container_id, logToPage]);
useEffect(() => {
const handleCornerstoneViewportEvent = () => {
const state = wsStateRef.current;
if (state.connected && state.isController && !restoringStateRef.current) {
throttleSendViewerState();
}
};
const handleCornerstoneAnnotationEvent = () => {
const state = wsStateRef.current;
if (state.connected && state.isController && !restoringStateRef.current) {
throttleSendAnnotations();
}
};
cornerstone3d.eventTarget.addEventListener(cornerstone3d.Enums.Events.CAMERA_MODIFIED, handleCornerstoneViewportEvent);
cornerstone3d.eventTarget.addEventListener(cornerstone3d.Enums.Events.VOI_MODIFIED, handleCornerstoneViewportEvent);
cornerstone3d.eventTarget.addEventListener(cornerstone3d.Enums.Events.IMAGE_RENDERED, handleCornerstoneViewportEvent);
cornerstone3d.eventTarget.addEventListener(cornerstone3d.Enums.Events.VOLUME_NEW_IMAGE, handleCornerstoneViewportEvent);
cornerstone3d.eventTarget.addEventListener(cornerstoneTools.Enums.Events.ANNOTATION_ADDED, handleCornerstoneAnnotationEvent);
cornerstone3d.eventTarget.addEventListener(cornerstoneTools.Enums.Events.ANNOTATION_MODIFIED, handleCornerstoneAnnotationEvent);
cornerstone3d.eventTarget.addEventListener(cornerstoneTools.Enums.Events.ANNOTATION_REMOVED, handleCornerstoneAnnotationEvent);
cornerstone3d.eventTarget.addEventListener(cornerstoneTools.Enums.Events.ANNOTATION_COMPLETED, handleCornerstoneAnnotationEvent);
return () => {
cornerstone3d.eventTarget.removeEventListener(cornerstone3d.Enums.Events.CAMERA_MODIFIED, handleCornerstoneViewportEvent);
cornerstone3d.eventTarget.removeEventListener(cornerstone3d.Enums.Events.VOI_MODIFIED, handleCornerstoneViewportEvent);
cornerstone3d.eventTarget.removeEventListener(cornerstone3d.Enums.Events.IMAGE_RENDERED, handleCornerstoneViewportEvent);
cornerstone3d.eventTarget.removeEventListener(cornerstone3d.Enums.Events.VOLUME_NEW_IMAGE, handleCornerstoneViewportEvent);
cornerstone3d.eventTarget.removeEventListener(cornerstoneTools.Enums.Events.ANNOTATION_ADDED, handleCornerstoneAnnotationEvent);
cornerstone3d.eventTarget.removeEventListener(cornerstoneTools.Enums.Events.ANNOTATION_MODIFIED, handleCornerstoneAnnotationEvent);
cornerstone3d.eventTarget.removeEventListener(cornerstoneTools.Enums.Events.ANNOTATION_REMOVED, handleCornerstoneAnnotationEvent);
cornerstone3d.eventTarget.removeEventListener(cornerstoneTools.Enums.Events.ANNOTATION_COMPLETED, handleCornerstoneAnnotationEvent);
if (stateThrottleTimeoutRef.current !== null) window.clearTimeout(stateThrottleTimeoutRef.current);
if (annotationsThrottleTimeoutRef.current !== null) window.clearTimeout(annotationsThrottleTimeoutRef.current);
};
}, [throttleSendViewerState, throttleSendAnnotations]);
const [annotationNavEnabled, setAnnotationNavEnabled] = useState(true); const [annotationNavEnabled, setAnnotationNavEnabled] = useState(true);
const [volumeDiagnosticsEnabled, setVolumeDiagnosticsEnabled] = useState(false); const [volumeDiagnosticsEnabled, setVolumeDiagnosticsEnabled] = useState(false);
@@ -4464,6 +4443,64 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
cancelAnimationFrame(raf); cancelAnimationFrame(raf);
}; };
}, [elementRef, updateOverlay, viewportGrid, viewportModes, viewportImageIds, crossHairsEnabled]); }, [elementRef, updateOverlay, viewportGrid, viewportModes, viewportImageIds, crossHairsEnabled]);
useEffect(() => {
const handleCornerstoneViewportEvent = (evt: any) => {
const state = wsStateRef.current;
appLogger.debug(`Viewport event received on element: ${evt?.type}, connected=${state.connected}, isController=${state.isController}, restoring=${restoringStateRef.current}`);
if (state.connected && state.isController && !restoringStateRef.current) {
throttleSendViewerState();
}
};
const handleCornerstoneAnnotationEvent = () => {
const state = wsStateRef.current;
if (state.connected && state.isController && !restoringStateRef.current) {
throttleSendAnnotations();
}
};
// Viewport events are triggered on individual viewport HTML elements (not on eventTarget globally)
const viewportEvents = [
cornerstone3d.Enums.Events.CAMERA_MODIFIED,
cornerstone3d.Enums.Events.VOI_MODIFIED,
cornerstone3d.Enums.Events.IMAGE_RENDERED,
cornerstone3d.Enums.Events.VOLUME_NEW_IMAGE,
];
const activeElements: HTMLDivElement[] = [];
viewportRefs.current.forEach((el) => {
if (el) {
viewportEvents.forEach(eventName => {
el.addEventListener(eventName, handleCornerstoneViewportEvent);
});
activeElements.push(el);
}
});
// Annotation events trigger globally on the eventTarget
cornerstone3d.eventTarget.addEventListener(cornerstoneTools.Enums.Events.ANNOTATION_ADDED, handleCornerstoneAnnotationEvent);
cornerstone3d.eventTarget.addEventListener(cornerstoneTools.Enums.Events.ANNOTATION_MODIFIED, handleCornerstoneAnnotationEvent);
cornerstone3d.eventTarget.addEventListener(cornerstoneTools.Enums.Events.ANNOTATION_REMOVED, handleCornerstoneAnnotationEvent);
cornerstone3d.eventTarget.addEventListener(cornerstoneTools.Enums.Events.ANNOTATION_COMPLETED, handleCornerstoneAnnotationEvent);
return () => {
activeElements.forEach((el) => {
viewportEvents.forEach(eventName => {
el.removeEventListener(eventName, handleCornerstoneViewportEvent);
});
});
cornerstone3d.eventTarget.removeEventListener(cornerstoneTools.Enums.Events.ANNOTATION_ADDED, handleCornerstoneAnnotationEvent);
cornerstone3d.eventTarget.removeEventListener(cornerstoneTools.Enums.Events.ANNOTATION_MODIFIED, handleCornerstoneAnnotationEvent);
cornerstone3d.eventTarget.removeEventListener(cornerstoneTools.Enums.Events.ANNOTATION_REMOVED, handleCornerstoneAnnotationEvent);
cornerstone3d.eventTarget.removeEventListener(cornerstoneTools.Enums.Events.ANNOTATION_COMPLETED, handleCornerstoneAnnotationEvent);
if (stateThrottleTimeoutRef.current !== null) window.clearTimeout(stateThrottleTimeoutRef.current);
if (annotationsThrottleTimeoutRef.current !== null) window.clearTimeout(annotationsThrottleTimeoutRef.current);
};
}, [throttleSendViewerState, throttleSendAnnotations, viewportGrid, viewportModes, viewportImageIds]);
// Synchronize Crosshairs Projection Type // Synchronize Crosshairs Projection Type
useEffect(() => { useEffect(() => {
if (!crossHairsEnabled) return; if (!crossHairsEnabled) return;
@@ -5644,22 +5681,6 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
appLogger.debug("Checking viewport", i); appLogger.debug("Checking viewport", i);
const viewport = renderingEngine.getViewport(`CT_${i}`); const viewport = renderingEngine.getViewport(`CT_${i}`);
if (!viewport) { allReady = false; break; } if (!viewport) { allReady = false; break; }
const stackIdx = state.stackIdx?.[i];
const stackObj = state.stacks?.[stackIdx];
const importedImageIds = (stackObj?.i || []).map(rewriteImageId);
if (!importedImageIds.length) continue;
try {
const viewportImageIds = viewport.getImageIds?.() || [];
if (
viewportImageIds.length !== importedImageIds.length ||
!viewportImageIds.every((id, idx) => id === importedImageIds[idx])
) {
allReady = false;
break;
}
} catch (e) {
allReady = false;
}
} }
if (!allReady && attempts < maxAttempts) { if (!allReady && attempts < maxAttempts) {
attempts++; attempts++;
+14 -1
View File
@@ -49,7 +49,7 @@ type RootRecord = {
signature: string; signature: string;
}; };
const rootRegistry = new WeakMap<HTMLElement, RootRecord>(); const rootRegistry = new Map<HTMLElement, RootRecord>();
const mainLogger = logger.child("main"); const mainLogger = logger.child("main");
declare global { declare global {
@@ -241,6 +241,19 @@ const mountContainer = (container: HTMLElement, imageStacks: () => Promise<any[]
}; };
export function mountDicomViewers(options?: MountOptions) { export function mountDicomViewers(options?: MountOptions) {
// Clean up any stale roots from elements that have been removed from the DOM
for (const [container, entry] of rootRegistry.entries()) {
if (!document.body.contains(container)) {
try {
mainLogger.info("Unmounting stale React root for container that was removed from DOM:", container.id);
entry.root.unmount();
} catch (e) {
mainLogger.warn("Failed to unmount stale React root", e);
}
rootRegistry.delete(container);
}
}
// Test containers // Test containers
const testContainers = document.querySelectorAll(".dicom-viewer-test-root"); const testContainers = document.querySelectorAll(".dicom-viewer-test-root");
testContainers.forEach((container) => { testContainers.forEach((container) => {