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 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++;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user