imrove multiuser sync support
This commit is contained in:
+93
-72
@@ -527,6 +527,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
|
||||
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({
|
||||
connected: false,
|
||||
isController: false,
|
||||
@@ -597,6 +603,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
const content = JSON.parse(messageEvent.data);
|
||||
const type = content.type;
|
||||
appLogger.info("Viewer WS event received (type=" + type + "):", content);
|
||||
logToPage(`[Viewer WS] Received message (type=${type})`, content);
|
||||
|
||||
if (type === "welcome") {
|
||||
setMyChannelName(content.channel_name);
|
||||
@@ -641,7 +648,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
setWsError(`Error initializing connection: ${e.message}`);
|
||||
setWsConnecting(false);
|
||||
}
|
||||
}, [container_id]);
|
||||
}, [container_id, logToPage]);
|
||||
|
||||
const disconnectMultiuser = useCallback(() => {
|
||||
appLogger.info("Disconnecting viewer WS connection");
|
||||
@@ -661,35 +668,43 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
const requestControl = useCallback(() => {
|
||||
if (webSocketRef.current && wsConnected) {
|
||||
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(() => {
|
||||
if (webSocketRef.current && wsConnected && isStaff) {
|
||||
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) => {
|
||||
if (webSocketRef.current && wsConnected) {
|
||||
appLogger.info("Sending grant_control via WS to:", targetUsername, targetChannel);
|
||||
webSocketRef.current.send(JSON.stringify({
|
||||
const msg = {
|
||||
type: "grant_control",
|
||||
target_channel: targetChannel,
|
||||
target_username: targetUsername
|
||||
}));
|
||||
};
|
||||
webSocketRef.current.send(JSON.stringify(msg));
|
||||
logToPage("[Viewer WS] Sent message (type=grant_control)", msg);
|
||||
setControlRequestNotification(null);
|
||||
}
|
||||
}, [wsConnected]);
|
||||
}, [wsConnected, logToPage]);
|
||||
|
||||
const releaseControl = useCallback(() => {
|
||||
if (webSocketRef.current && wsConnected) {
|
||||
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(() => {
|
||||
if (multiuserRoom) {
|
||||
@@ -723,10 +738,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
lastStateSentRef.current = jsonState;
|
||||
if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) {
|
||||
appLogger.info("Sending viewer_state via WS:", jsonState);
|
||||
webSocketRef.current.send(JSON.stringify({
|
||||
const msg = {
|
||||
type: "viewer_state",
|
||||
data: jsonState
|
||||
}));
|
||||
};
|
||||
webSocketRef.current.send(JSON.stringify(msg));
|
||||
logToPage("[Viewer WS] Sent message (type=viewer_state)", msg);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -734,7 +751,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
}
|
||||
}
|
||||
}, 120);
|
||||
}, [container_id]);
|
||||
}, [container_id, logToPage]);
|
||||
|
||||
// Throttled annotations send
|
||||
const lastAnnotationsSentRef = useRef<string>("");
|
||||
@@ -757,10 +774,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
lastAnnotationsSentRef.current = jsonAnn;
|
||||
if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) {
|
||||
appLogger.info("Sending annotations via WS:", jsonAnn);
|
||||
webSocketRef.current.send(JSON.stringify({
|
||||
const msg = {
|
||||
type: "annotations",
|
||||
data: jsonAnn
|
||||
}));
|
||||
};
|
||||
webSocketRef.current.send(JSON.stringify(msg));
|
||||
logToPage("[Viewer WS] Sent message (type=annotations)", msg);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -768,48 +787,8 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
}
|
||||
}
|
||||
}, 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 [volumeDiagnosticsEnabled, setVolumeDiagnosticsEnabled] = useState(false);
|
||||
@@ -4464,6 +4443,64 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (!crossHairsEnabled) return;
|
||||
@@ -5644,22 +5681,6 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
appLogger.debug("Checking viewport", i);
|
||||
const viewport = renderingEngine.getViewport(`CT_${i}`);
|
||||
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) {
|
||||
attempts++;
|
||||
|
||||
@@ -49,7 +49,7 @@ type RootRecord = {
|
||||
signature: string;
|
||||
};
|
||||
|
||||
const rootRegistry = new WeakMap<HTMLElement, RootRecord>();
|
||||
const rootRegistry = new Map<HTMLElement, RootRecord>();
|
||||
const mainLogger = logger.child("main");
|
||||
|
||||
declare global {
|
||||
@@ -241,6 +241,19 @@ const mountContainer = (container: HTMLElement, imageStacks: () => Promise<any[]
|
||||
};
|
||||
|
||||
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
|
||||
const testContainers = document.querySelectorAll(".dicom-viewer-test-root");
|
||||
testContainers.forEach((container) => {
|
||||
|
||||
Reference in New Issue
Block a user