multiuser sync updates
This commit is contained in:
+651
-37
@@ -440,11 +440,13 @@ type AppProps = {
|
|||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
format?: LogFormat;
|
format?: LogFormat;
|
||||||
};
|
};
|
||||||
// ...other props if needed
|
multiuserRoom?: string;
|
||||||
|
username?: string;
|
||||||
|
isStaff?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// App definintion
|
// App definintion
|
||||||
export default function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewerState, initialLoggerConfig }: AppProps) {
|
export default function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewerState, initialLoggerConfig, multiuserRoom, username, isStaff }: AppProps) {
|
||||||
const appRootRef = useRef<HTMLDivElement>(null);
|
const appRootRef = useRef<HTMLDivElement>(null);
|
||||||
const elementRef = useRef<HTMLDivElement>(null)
|
const elementRef = useRef<HTMLDivElement>(null)
|
||||||
const running = useRef(false)
|
const running = useRef(false)
|
||||||
@@ -511,6 +513,304 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
|
|
||||||
const restoringStateRef = useRef(false);
|
const restoringStateRef = useRef(false);
|
||||||
|
|
||||||
|
// --- Multiuser Sync ---
|
||||||
|
const [wsConnected, setWsConnected] = useState(false);
|
||||||
|
const [wsConnecting, setWsConnecting] = useState(false);
|
||||||
|
const [wsError, setWsError] = useState<string | null>(null);
|
||||||
|
const [roomUsers, setRoomUsers] = useState<Record<string, { username: string; is_staff: boolean }>>({});
|
||||||
|
const [controllerChannel, setControllerChannel] = useState<string | null>(null);
|
||||||
|
const [controllerUsername, setControllerUsername] = useState<string | null>(null);
|
||||||
|
const [customRoomName, setCustomRoomName] = useState(multiuserRoom || "");
|
||||||
|
const [customUsername, setCustomUsername] = useState(username || "");
|
||||||
|
const [myChannelName, setMyChannelName] = useState<string | null>(null);
|
||||||
|
const [controlRequestNotification, setControlRequestNotification] = useState<{ senderName: string, senderChannel: string } | null>(null);
|
||||||
|
|
||||||
|
const webSocketRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
const wsStateRef = useRef({
|
||||||
|
connected: false,
|
||||||
|
isController: false,
|
||||||
|
containerId: container_id,
|
||||||
|
myChannelName: null as string | null,
|
||||||
|
isStaff: isStaff || false
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isCtrl = wsConnected && controllerChannel !== null && myChannelName !== null && controllerChannel === myChannelName;
|
||||||
|
wsStateRef.current = {
|
||||||
|
connected: wsConnected,
|
||||||
|
isController: isCtrl,
|
||||||
|
containerId: container_id,
|
||||||
|
myChannelName: myChannelName,
|
||||||
|
isStaff: isStaff || false
|
||||||
|
};
|
||||||
|
}, [wsConnected, controllerChannel, myChannelName, container_id, isStaff]);
|
||||||
|
|
||||||
|
const connectMultiuser = useCallback((roomName: string, userDisplayName: string) => {
|
||||||
|
if (webSocketRef.current) {
|
||||||
|
webSocketRef.current.close();
|
||||||
|
}
|
||||||
|
if (!roomName.trim()) {
|
||||||
|
setWsError("Room name cannot be empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWsConnecting(true);
|
||||||
|
setWsError(null);
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const host = window.location.host || "localhost:8080";
|
||||||
|
const wsUrl = `${protocol}//${host}/ws/multiuser/${roomName.trim()}/?client_type=viewer`;
|
||||||
|
|
||||||
|
appLogger.info("Connecting viewer WS to:", wsUrl);
|
||||||
|
try {
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
webSocketRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
appLogger.info("Viewer WS connected successfully");
|
||||||
|
setWsConnected(true);
|
||||||
|
setWsConnecting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
appLogger.info("Viewer WS closed. code:", event.code);
|
||||||
|
setWsConnected(false);
|
||||||
|
setWsConnecting(false);
|
||||||
|
setMyChannelName(null);
|
||||||
|
setRoomUsers({});
|
||||||
|
setControllerChannel(null);
|
||||||
|
setControllerUsername(null);
|
||||||
|
setControlRequestNotification(null);
|
||||||
|
if (event.code !== 1000) {
|
||||||
|
setWsError(`Disconnected (Code: ${event.code})`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (err) => {
|
||||||
|
appLogger.error("Viewer WS error occurred:", err);
|
||||||
|
setWsError("Failed to connect to real-time server");
|
||||||
|
setWsConnecting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (messageEvent) => {
|
||||||
|
try {
|
||||||
|
const content = JSON.parse(messageEvent.data);
|
||||||
|
const type = content.type;
|
||||||
|
appLogger.info("Viewer WS event received (type=" + type + "):", content);
|
||||||
|
|
||||||
|
if (type === "welcome") {
|
||||||
|
setMyChannelName(content.channel_name);
|
||||||
|
} else if (type === "room_status") {
|
||||||
|
setRoomUsers(content.users || {});
|
||||||
|
setControllerChannel(content.controller_channel);
|
||||||
|
setControllerUsername(content.controller_username);
|
||||||
|
} else if (type === "control_request") {
|
||||||
|
const state = wsStateRef.current;
|
||||||
|
if (state.isController || state.isStaff) {
|
||||||
|
setControlRequestNotification({
|
||||||
|
senderName: content.sender_name,
|
||||||
|
senderChannel: content.sender_channel
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (type === "viewer_state") {
|
||||||
|
const state = wsStateRef.current;
|
||||||
|
if (!state.isController) {
|
||||||
|
const exportFnName = `importViewerState_${container_id || "default"}`;
|
||||||
|
if (typeof window[exportFnName] === "function") {
|
||||||
|
appLogger.debug("Applying external viewer state synchronization:", content.data);
|
||||||
|
window[exportFnName](content.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (type === "annotations") {
|
||||||
|
const state = wsStateRef.current;
|
||||||
|
if (!state.isController) {
|
||||||
|
const importAnnFnName = `importAnnotations_${container_id || "default"}`;
|
||||||
|
if (typeof window[importAnnFnName] === "function") {
|
||||||
|
appLogger.debug("Applying external annotations synchronization:", content.data);
|
||||||
|
window[importAnnFnName](content.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.error("Failed to parse websocket message", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
appLogger.error("Error initializing viewer WS connection:", e);
|
||||||
|
setWsError(`Error initializing connection: ${e.message}`);
|
||||||
|
setWsConnecting(false);
|
||||||
|
}
|
||||||
|
}, [container_id]);
|
||||||
|
|
||||||
|
const disconnectMultiuser = useCallback(() => {
|
||||||
|
appLogger.info("Disconnecting viewer WS connection");
|
||||||
|
if (webSocketRef.current) {
|
||||||
|
webSocketRef.current.close();
|
||||||
|
webSocketRef.current = null;
|
||||||
|
}
|
||||||
|
setWsConnected(false);
|
||||||
|
setWsConnecting(false);
|
||||||
|
setMyChannelName(null);
|
||||||
|
setRoomUsers({});
|
||||||
|
setControllerChannel(null);
|
||||||
|
setControllerUsername(null);
|
||||||
|
setControlRequestNotification(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestControl = useCallback(() => {
|
||||||
|
if (webSocketRef.current && wsConnected) {
|
||||||
|
appLogger.info("Sending request_control via WS");
|
||||||
|
webSocketRef.current.send(JSON.stringify({ type: "request_control" }));
|
||||||
|
}
|
||||||
|
}, [wsConnected]);
|
||||||
|
|
||||||
|
const takeControl = useCallback(() => {
|
||||||
|
if (webSocketRef.current && wsConnected && isStaff) {
|
||||||
|
appLogger.info("Sending take_control via WS");
|
||||||
|
webSocketRef.current.send(JSON.stringify({ type: "take_control" }));
|
||||||
|
}
|
||||||
|
}, [wsConnected, isStaff]);
|
||||||
|
|
||||||
|
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({
|
||||||
|
type: "grant_control",
|
||||||
|
target_channel: targetChannel,
|
||||||
|
target_username: targetUsername
|
||||||
|
}));
|
||||||
|
setControlRequestNotification(null);
|
||||||
|
}
|
||||||
|
}, [wsConnected]);
|
||||||
|
|
||||||
|
const releaseControl = useCallback(() => {
|
||||||
|
if (webSocketRef.current && wsConnected) {
|
||||||
|
appLogger.info("Sending release_control via WS");
|
||||||
|
webSocketRef.current.send(JSON.stringify({ type: "release_control" }));
|
||||||
|
}
|
||||||
|
}, [wsConnected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (multiuserRoom) {
|
||||||
|
connectMultiuser(multiuserRoom, username || "User");
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (webSocketRef.current) {
|
||||||
|
webSocketRef.current.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [multiuserRoom, username, connectMultiuser]);
|
||||||
|
|
||||||
|
// Throttled view state send
|
||||||
|
const lastStateSentRef = useRef<string>("");
|
||||||
|
const stateThrottleTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const throttleSendViewerState = useCallback(() => {
|
||||||
|
if (stateThrottleTimeoutRef.current !== null) return;
|
||||||
|
|
||||||
|
stateThrottleTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
stateThrottleTimeoutRef.current = null;
|
||||||
|
|
||||||
|
const state = wsStateRef.current;
|
||||||
|
if (!state.connected || !state.isController || restoringStateRef.current) return;
|
||||||
|
|
||||||
|
const exportFnName = `exportViewerState_${container_id || "default"}`;
|
||||||
|
if (typeof window[exportFnName] === "function") {
|
||||||
|
try {
|
||||||
|
const jsonState = window[exportFnName]();
|
||||||
|
if (jsonState && jsonState !== lastStateSentRef.current) {
|
||||||
|
lastStateSentRef.current = jsonState;
|
||||||
|
if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) {
|
||||||
|
appLogger.info("Sending viewer_state via WS:", jsonState);
|
||||||
|
webSocketRef.current.send(JSON.stringify({
|
||||||
|
type: "viewer_state",
|
||||||
|
data: jsonState
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
appLogger.error("Failed to export/send viewer state", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 120);
|
||||||
|
}, [container_id]);
|
||||||
|
|
||||||
|
// Throttled annotations send
|
||||||
|
const lastAnnotationsSentRef = useRef<string>("");
|
||||||
|
const annotationsThrottleTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const throttleSendAnnotations = useCallback(() => {
|
||||||
|
if (annotationsThrottleTimeoutRef.current !== null) return;
|
||||||
|
|
||||||
|
annotationsThrottleTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
annotationsThrottleTimeoutRef.current = null;
|
||||||
|
|
||||||
|
const state = wsStateRef.current;
|
||||||
|
if (!state.connected || !state.isController || restoringStateRef.current) return;
|
||||||
|
|
||||||
|
const exportFnName = `exportAnnotations_${container_id || "default"}`;
|
||||||
|
if (typeof window[exportFnName] === "function") {
|
||||||
|
try {
|
||||||
|
const jsonAnn = window[exportFnName]();
|
||||||
|
if (jsonAnn && jsonAnn !== lastAnnotationsSentRef.current) {
|
||||||
|
lastAnnotationsSentRef.current = jsonAnn;
|
||||||
|
if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) {
|
||||||
|
appLogger.info("Sending annotations via WS:", jsonAnn);
|
||||||
|
webSocketRef.current.send(JSON.stringify({
|
||||||
|
type: "annotations",
|
||||||
|
data: jsonAnn
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
appLogger.error("Failed to export/send annotations", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}, [container_id]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
@@ -5282,16 +5582,36 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
try {
|
try {
|
||||||
restoringStateRef.current = true;
|
restoringStateRef.current = true;
|
||||||
const state = typeof json === "string" ? JSON.parse(json) : json;
|
const state = typeof json === "string" ? JSON.parse(json) : json;
|
||||||
|
|
||||||
|
const rewriteImageId = (id: string) => {
|
||||||
|
if (!id) return id;
|
||||||
|
const schemeMatch = id.match(/^((?:wadouri|wadors|dicomweb):)?(.*)$/);
|
||||||
|
if (!schemeMatch) return id;
|
||||||
|
const scheme = schemeMatch[1] || "";
|
||||||
|
const urlPart = schemeMatch[2];
|
||||||
|
if (urlPart.startsWith("http://") || urlPart.startsWith("https://")) {
|
||||||
|
try {
|
||||||
|
const url = new URL(urlPart);
|
||||||
|
url.protocol = window.location.protocol;
|
||||||
|
url.host = window.location.host;
|
||||||
|
return scheme + url.toString();
|
||||||
|
} catch (e) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
if (state.grid) setViewportGrid(state.grid);
|
if (state.grid) setViewportGrid(state.grid);
|
||||||
if (state.modes) setViewportModes(state.modes);
|
if (state.modes) setViewportModes(state.modes);
|
||||||
if (state.stacks) setAvailableStacks(state.stacks.map((s: any) => ({
|
if (state.stacks) setAvailableStacks(state.stacks.map((s: any) => ({
|
||||||
imageIds: s.i,
|
imageIds: (s.i || []).map(rewriteImageId),
|
||||||
studyInstanceUID: s.u,
|
studyInstanceUID: s.u,
|
||||||
seriesGroups: Array.isArray(s.g) ? s.g : undefined,
|
seriesGroups: Array.isArray(s.g) ? s.g : undefined,
|
||||||
})));
|
})));
|
||||||
if (state.stackIdx && state.stacks) {
|
if (state.stackIdx && state.stacks) {
|
||||||
const reconstructed = state.stackIdx.map(
|
const reconstructed = state.stackIdx.map(
|
||||||
(idx: number) => state.stacks[idx]?.i || []
|
(idx: number) => (state.stacks[idx]?.i || []).map(rewriteImageId)
|
||||||
);
|
);
|
||||||
setViewportImageIds(reconstructed);
|
setViewportImageIds(reconstructed);
|
||||||
setSelectedStackIdx(state.stackIdx);
|
setSelectedStackIdx(state.stackIdx);
|
||||||
@@ -5326,7 +5646,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
if (!viewport) { allReady = false; break; }
|
if (!viewport) { allReady = false; break; }
|
||||||
const stackIdx = state.stackIdx?.[i];
|
const stackIdx = state.stackIdx?.[i];
|
||||||
const stackObj = state.stacks?.[stackIdx];
|
const stackObj = state.stacks?.[stackIdx];
|
||||||
const importedImageIds = stackObj?.i || [];
|
const importedImageIds = (stackObj?.i || []).map(rewriteImageId);
|
||||||
if (!importedImageIds.length) continue;
|
if (!importedImageIds.length) continue;
|
||||||
try {
|
try {
|
||||||
const viewportImageIds = viewport.getImageIds?.() || [];
|
const viewportImageIds = viewport.getImageIds?.() || [];
|
||||||
@@ -5334,16 +5654,11 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
viewportImageIds.length !== importedImageIds.length ||
|
viewportImageIds.length !== importedImageIds.length ||
|
||||||
!viewportImageIds.every((id, idx) => id === importedImageIds[idx])
|
!viewportImageIds.every((id, idx) => id === importedImageIds[idx])
|
||||||
) {
|
) {
|
||||||
appLogger.warn(
|
|
||||||
`Viewport ${i}: importedImageIds do not match viewportImageIds`,
|
|
||||||
{ importedImageIds, viewportImageIds }
|
|
||||||
);
|
|
||||||
allReady = false;
|
allReady = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
allReady = false;
|
allReady = false;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!allReady && attempts < maxAttempts) {
|
if (!allReady && attempts < maxAttempts) {
|
||||||
@@ -5352,6 +5667,8 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let anyStackReloaded = false;
|
||||||
|
|
||||||
// First pass: set stacks/volumes and stack positions so viewports have image resources
|
// First pass: set stacks/volumes and stack positions so viewports have image resources
|
||||||
for (let i = 0; i < numActive; i++) {
|
for (let i = 0; i < numActive; i++) {
|
||||||
appLogger.debug("Setting stack/volume for viewport", i);
|
appLogger.debug("Setting stack/volume for viewport", i);
|
||||||
@@ -5359,51 +5676,69 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
if (!viewport) continue;
|
if (!viewport) continue;
|
||||||
const stackIdx = state.stackIdx?.[i];
|
const stackIdx = state.stackIdx?.[i];
|
||||||
const stackObj = state.stacks?.[stackIdx];
|
const stackObj = state.stacks?.[stackIdx];
|
||||||
const importedImageIds = stackObj?.i || [];
|
const importedImageIds = (stackObj?.i || []).map(rewriteImageId);
|
||||||
if (!importedImageIds.length) continue;
|
if (!importedImageIds.length) continue;
|
||||||
|
|
||||||
appLogger.debug("mode", state.modes?.[i]);
|
appLogger.debug("mode", state.modes?.[i]);
|
||||||
|
|
||||||
if (state.modes?.[i] === "volume") {
|
if (state.modes?.[i] === "volume") {
|
||||||
try {
|
const volumeId = `myVolumeId_${i}`;
|
||||||
// Force cache metadata for the volume to prevent undefined extent errors
|
const hasActor = viewport.getActorUIDs().includes(volumeId);
|
||||||
await setLoadedImageIdsAndCacheMeta(importedImageIds, { force: true });
|
if (!hasActor) {
|
||||||
|
anyStackReloaded = true;
|
||||||
|
try {
|
||||||
|
// Force cache metadata for the volume to prevent undefined extent errors
|
||||||
|
await setLoadedImageIdsAndCacheMeta(importedImageIds, { force: true });
|
||||||
|
|
||||||
const volumeId = `myVolumeId_${i}`;
|
try {
|
||||||
try {
|
if (viewport.getActorUIDs().includes(volumeId)) {
|
||||||
if (viewport.getActorUIDs().includes(volumeId)) {
|
viewport.removeActors([volumeId]);
|
||||||
viewport.removeActors([volumeId]);
|
}
|
||||||
}
|
} catch (e) { /* ignore */ }
|
||||||
} catch (e) { /* ignore */ }
|
try {
|
||||||
try {
|
cornerstone3d.cache.removeVolumeLoadObject(volumeId);
|
||||||
cornerstone3d.cache.removeVolumeLoadObject(volumeId);
|
} catch (e) { /* ignore */ }
|
||||||
} catch (e) { /* ignore */ }
|
const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: importedImageIds });
|
||||||
const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: importedImageIds });
|
volume.load();
|
||||||
volume.load();
|
await viewport.setVolumes([{ volumeId }]);
|
||||||
await viewport.setVolumes([{ volumeId }]);
|
viewport.render();
|
||||||
viewport.render();
|
} catch (e) {
|
||||||
} catch (e) {
|
appLogger.warn("Failed to create/load volume for viewport", i, e);
|
||||||
appLogger.warn("Failed to create/load volume for viewport", i, e);
|
}
|
||||||
}
|
}
|
||||||
// Best-effort orientation restore
|
// Best-effort orientation restore
|
||||||
if (state.orientations && state.orientations[i] && typeof viewport.setOrientation === "function") {
|
if (state.orientations && state.orientations[i] && typeof viewport.setOrientation === "function") {
|
||||||
try { viewport.setOrientation(state.orientations[i]); viewport.render(); } catch (e) { /* ignore */ }
|
try { viewport.setOrientation(state.orientations[i]); viewport.render(); } catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
if (state.volumeSliceIndices) {
|
if (state.volumeSliceIndices) {
|
||||||
setTimeout(() => { try { csUtilities.jumpToSlice(viewport.element, { imageIndex: state.volumeSliceIndices[i] }); } catch (e) { } }, 100);
|
try { csUtilities.jumpToSlice(viewport.element, { imageIndex: state.volumeSliceIndices[i] }); } catch (e) { }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try { if (viewport.setStack) { await viewport.setStack(importedImageIds); viewport.render(); } } catch (e) { appLogger.warn('Failed to set stack for viewport', i, e); }
|
const currentIds = viewport.getImageIds?.() || [];
|
||||||
|
const isMatch = currentIds.length === importedImageIds.length &&
|
||||||
|
currentIds.every((id, idx) => id === importedImageIds[idx]);
|
||||||
|
if (!isMatch) {
|
||||||
|
anyStackReloaded = true;
|
||||||
|
try { if (viewport.setStack) { await viewport.setStack(importedImageIds); viewport.render(); } } catch (e) { appLogger.warn('Failed to set stack for viewport', i, e); }
|
||||||
|
}
|
||||||
if (state.stackPos && typeof state.stackPos[i] === "number" && typeof viewport.setImageIdIndex === "function") {
|
if (state.stackPos && typeof state.stackPos[i] === "number" && typeof viewport.setImageIdIndex === "function") {
|
||||||
try { if (csUtilities && csUtilities.jumpToSlice) csUtilities.jumpToSlice(viewport.element, { imageIndex: state.stackPos[i] }); else viewport.setImageIdIndex(state.stackPos[i]); } catch (e) { /* ignore */ }
|
try {
|
||||||
|
if (csUtilities && csUtilities.jumpToSlice) {
|
||||||
|
csUtilities.jumpToSlice(viewport.element, { imageIndex: state.stackPos[i] });
|
||||||
|
} else {
|
||||||
|
viewport.setImageIdIndex(state.stackPos[i]);
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait briefly to allow viewports to initialize GPU/actor resources
|
// Only delay if we actually reloaded any stacks/volumes to let GPU catch up
|
||||||
const readyDelayMs = 1500;
|
if (anyStackReloaded) {
|
||||||
appLogger.debug(`Waiting ${readyDelayMs}ms before applying properties/camera to viewports`);
|
const readyDelayMs = 1500;
|
||||||
await new Promise((res) => setTimeout(res, readyDelayMs));
|
appLogger.debug(`Waiting ${readyDelayMs}ms before applying properties/camera to viewports`);
|
||||||
|
await new Promise((res) => setTimeout(res, readyDelayMs));
|
||||||
|
}
|
||||||
|
|
||||||
// Second pass: apply renderer-sensitive properties (colormap, VOI) and camera
|
// Second pass: apply renderer-sensitive properties (colormap, VOI) and camera
|
||||||
for (let i = 0; i < numActive; i++) {
|
for (let i = 0; i < numActive; i++) {
|
||||||
@@ -5412,7 +5747,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
if (!viewport) continue;
|
if (!viewport) continue;
|
||||||
const stackIdx = state.stackIdx?.[i];
|
const stackIdx = state.stackIdx?.[i];
|
||||||
const stackObj = state.stacks?.[stackIdx];
|
const stackObj = state.stacks?.[stackIdx];
|
||||||
const importedImageIds = stackObj?.i || [];
|
const importedImageIds = (stackObj?.i || []).map(rewriteImageId);
|
||||||
if (!importedImageIds.length) continue;
|
if (!importedImageIds.length) continue;
|
||||||
|
|
||||||
// Restore properties with sanitization + retry
|
// Restore properties with sanitization + retry
|
||||||
@@ -7450,6 +7785,285 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Multiuser Sync Control Card */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "rgba(255, 255, 255, 0.05)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "14px",
|
||||||
|
marginBottom: "18px",
|
||||||
|
color: "#e0e0e0",
|
||||||
|
fontSize: "14px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "10px",
|
||||||
|
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: "bold", borderBottom: "1px solid rgba(255, 255, 255, 0.1)", paddingBottom: "6px", color: "#fff", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<span>👥 Multiuser Sync</span>
|
||||||
|
<span style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: wsConnected ? "#4caf50" : wsConnecting ? "#ffeb3b" : "#f44336",
|
||||||
|
display: "inline-block",
|
||||||
|
}} title={wsConnected ? "Connected" : wsConnecting ? "Connecting" : "Disconnected"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!wsConnected ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||||
|
<label style={{ fontSize: "11px", color: "#aaa" }}>ROOM ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customRoomName}
|
||||||
|
onChange={(e) => setCustomRoomName(e.target.value)}
|
||||||
|
placeholder="e.g. case_123"
|
||||||
|
disabled={wsConnecting}
|
||||||
|
style={{
|
||||||
|
padding: "6px 8px",
|
||||||
|
background: "#1e1e1e",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "13px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||||
|
<label style={{ fontSize: "11px", color: "#aaa" }}>YOUR NAME</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customUsername}
|
||||||
|
onChange={(e) => setCustomUsername(e.target.value)}
|
||||||
|
placeholder="e.g. Dr. Smith"
|
||||||
|
disabled={wsConnecting}
|
||||||
|
style={{
|
||||||
|
padding: "6px 8px",
|
||||||
|
background: "#1e1e1e",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "13px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => connectMultiuser(customRoomName, customUsername)}
|
||||||
|
disabled={wsConnecting || !customRoomName.trim()}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "#1976d2",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "13px",
|
||||||
|
marginTop: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{wsConnecting ? "Connecting..." : "Connect Session"}
|
||||||
|
</button>
|
||||||
|
{wsError && (
|
||||||
|
<div style={{ color: "#f44336", fontSize: "11px", marginTop: "4px" }}>
|
||||||
|
⚠️ {wsError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||||
|
<div style={{ fontSize: "12px", background: "rgba(0,0,0,0.2)", padding: "6px 8px", borderRadius: "4px" }}>
|
||||||
|
Connected to <strong>{customRoomName}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "2px", fontSize: "12px" }}>
|
||||||
|
<span style={{ color: "#aaa" }}>ACTIVE CONTROLLER:</span>
|
||||||
|
<span style={{ fontWeight: "bold", color: "#ffd54f" }}>
|
||||||
|
{controllerChannel === myChannelName ? "👑 You (In Control)" : `👤 ${controllerUsername || "Unknown"}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "6px", marginTop: "4px" }}>
|
||||||
|
{controllerChannel === myChannelName ? (
|
||||||
|
<button
|
||||||
|
onClick={releaseControl}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "6px 10px",
|
||||||
|
background: "#ffd54f",
|
||||||
|
color: "#000",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "11px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Relinquish Control
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={requestControl}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "6px 10px",
|
||||||
|
background: "#4caf50",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "11px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Request Control
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isStaff && controllerChannel !== myChannelName && (
|
||||||
|
<button
|
||||||
|
onClick={takeControl}
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
background: "#e53935",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "11px",
|
||||||
|
}}
|
||||||
|
title="Forcibly take control as supervisor"
|
||||||
|
>
|
||||||
|
Take
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "6px", borderTop: "1px solid rgba(255,255,255,0.1)", paddingTop: "6px" }}>
|
||||||
|
<div style={{ fontSize: "11px", color: "#aaa", marginBottom: "4px", fontWeight: "bold" }}>PARTICIPANTS ({Object.keys(roomUsers).length}):</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "4px", maxHeight: "120px", overflowY: "auto" }}>
|
||||||
|
{Object.entries(roomUsers).map(([channel, user]) => {
|
||||||
|
const isSelf = channel === myChannelName;
|
||||||
|
const isController = channel === controllerChannel;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={channel}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
fontSize: "12px",
|
||||||
|
padding: "2px 4px",
|
||||||
|
background: isSelf ? "rgba(255,255,255,0.05)" : "transparent",
|
||||||
|
borderRadius: "3px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ textOverflow: "ellipsis", overflow: "hidden", whiteSpace: "nowrap", maxWidth: "110px" }} title={user.username}>
|
||||||
|
{user.username} {user.is_staff && <span style={{ fontSize: "8px", background: "#1976d2", color: "#fff", padding: "1px 2px", borderRadius: "2px", marginLeft: "2px" }}>staff</span>}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||||
|
{isController && <span style={{ fontSize: "10px", color: "#ffd54f" }}>👑</span>}
|
||||||
|
{!isController && (controllerChannel === myChannelName || isStaff) && (
|
||||||
|
<button
|
||||||
|
onClick={() => grantControl(channel, user.username)}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid #4caf50",
|
||||||
|
color: "#4caf50",
|
||||||
|
borderRadius: "3px",
|
||||||
|
fontSize: "9px",
|
||||||
|
padding: "0px 3px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Grant
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={disconnectMultiuser}
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
background: "#333",
|
||||||
|
color: "#fff",
|
||||||
|
border: "1px solid #555",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "11px",
|
||||||
|
marginTop: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Disconnect Room
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Control Request Banner/Alert */}
|
||||||
|
{controlRequestNotification && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "rgba(76, 175, 80, 0.2)",
|
||||||
|
border: "1px solid #4caf50",
|
||||||
|
borderRadius: "4px",
|
||||||
|
padding: "8px 10px",
|
||||||
|
marginBottom: "18px",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#fff",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong>{controlRequestNotification.senderName}</strong> requests control.
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "6px" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => grantControl(controlRequestNotification.senderChannel, controlRequestNotification.senderName)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: "#4caf50",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "3px",
|
||||||
|
fontSize: "10px",
|
||||||
|
padding: "4px 8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Grant
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setControlRequestNotification(null)}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid #aaa",
|
||||||
|
color: "#ccc",
|
||||||
|
borderRadius: "3px",
|
||||||
|
fontSize: "10px",
|
||||||
|
padding: "4px 8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Decline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}>
|
||||||
<label style={{ fontSize: 13 }}><input type="radio" name="folderLoadMode" value="add" checked={folderLoadMode==='add'} onChange={()=>setFolderLoadMode('add')} /> Add (append)</label>
|
<label style={{ fontSize: 13 }}><input type="radio" name="folderLoadMode" value="add" checked={folderLoadMode==='add'} onChange={()=>setFolderLoadMode('add')} /> Add (append)</label>
|
||||||
<label style={{ fontSize: 13 }}><input type="radio" name="folderLoadMode" value="replace" checked={folderLoadMode==='replace'} onChange={()=>setFolderLoadMode('replace')} /> Replace all</label>
|
<label style={{ fontSize: 13 }}><input type="radio" name="folderLoadMode" value="replace" checked={folderLoadMode==='replace'} onChange={()=>setFolderLoadMode('replace')} /> Replace all</label>
|
||||||
|
|||||||
@@ -176,13 +176,21 @@ const shouldMountContainer = (container: HTMLElement, options?: MountOptions): b
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getContainerLogConfig = (container: HTMLElement, options?: MountOptions) => {
|
const getContainerLogConfig = (container: HTMLElement, options?: MountOptions) => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const urlDebug = urlParams.get("debug");
|
||||||
|
const localSyncDebug = typeof window !== "undefined" && window.localStorage ? window.localStorage.getItem("multiuser_sync_debug_mode") : null;
|
||||||
|
|
||||||
const attrLevel = container.getAttribute("data-log-level");
|
const attrLevel = container.getAttribute("data-log-level");
|
||||||
const attrDebug = container.getAttribute("data-log-debug");
|
const attrDebug = container.getAttribute("data-log-debug");
|
||||||
const attrFormat = container.getAttribute("data-log-format");
|
const attrFormat = container.getAttribute("data-log-format");
|
||||||
|
|
||||||
// Resolve minLevel: data-log-level > options.minLevel > data-log-debug (legacy) > options.debug (legacy)
|
// Resolve minLevel: LocalStorage debug > URL debug parameter > data-log-level > options.minLevel > data-log-debug (legacy)
|
||||||
let minLevel: LogLevel;
|
let minLevel: LogLevel;
|
||||||
if (attrLevel !== null) {
|
if (localSyncDebug === "true") {
|
||||||
|
minLevel = "debug";
|
||||||
|
} else if (urlDebug !== null) {
|
||||||
|
minLevel = parseDebugFlag(urlDebug);
|
||||||
|
} else if (attrLevel !== null) {
|
||||||
minLevel = parseLogLevel(attrLevel);
|
minLevel = parseLogLevel(attrLevel);
|
||||||
} else if (options?.logging?.minLevel) {
|
} else if (options?.logging?.minLevel) {
|
||||||
minLevel = options.logging.minLevel;
|
minLevel = options.logging.minLevel;
|
||||||
@@ -223,6 +231,9 @@ const mountContainer = (container: HTMLElement, imageStacks: () => Promise<any[]
|
|||||||
annotationJson={container.getAttribute("data-annotationjson") || undefined}
|
annotationJson={container.getAttribute("data-annotationjson") || undefined}
|
||||||
viewerState={container.getAttribute("data-viewerstate") || undefined}
|
viewerState={container.getAttribute("data-viewerstate") || undefined}
|
||||||
initialLoggerConfig={initialLoggerConfig}
|
initialLoggerConfig={initialLoggerConfig}
|
||||||
|
multiuserRoom={container.getAttribute("data-multiuser-room") || undefined}
|
||||||
|
username={container.getAttribute("data-username") || undefined}
|
||||||
|
isStaff={container.getAttribute("data-is-staff") === "true"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user