multiuser sync updates

This commit is contained in:
Ross
2026-06-22 09:37:08 +01:00
parent d0f3431ef4
commit 0c4da6e022
2 changed files with 664 additions and 39 deletions
+630 -16
View File
@@ -440,11 +440,13 @@ type AppProps = {
debug?: boolean;
format?: LogFormat;
};
// ...other props if needed
multiuserRoom?: string;
username?: string;
isStaff?: boolean;
};
// 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 elementRef = useRef<HTMLDivElement>(null)
const running = useRef(false)
@@ -511,6 +513,304 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
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 [volumeDiagnosticsEnabled, setVolumeDiagnosticsEnabled] = useState(false);
@@ -5282,16 +5582,36 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
try {
restoringStateRef.current = true;
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.modes) setViewportModes(state.modes);
if (state.stacks) setAvailableStacks(state.stacks.map((s: any) => ({
imageIds: s.i,
imageIds: (s.i || []).map(rewriteImageId),
studyInstanceUID: s.u,
seriesGroups: Array.isArray(s.g) ? s.g : undefined,
})));
if (state.stackIdx && state.stacks) {
const reconstructed = state.stackIdx.map(
(idx: number) => state.stacks[idx]?.i || []
(idx: number) => (state.stacks[idx]?.i || []).map(rewriteImageId)
);
setViewportImageIds(reconstructed);
setSelectedStackIdx(state.stackIdx);
@@ -5326,7 +5646,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
if (!viewport) { allReady = false; break; }
const stackIdx = state.stackIdx?.[i];
const stackObj = state.stacks?.[stackIdx];
const importedImageIds = stackObj?.i || [];
const importedImageIds = (stackObj?.i || []).map(rewriteImageId);
if (!importedImageIds.length) continue;
try {
const viewportImageIds = viewport.getImageIds?.() || [];
@@ -5334,16 +5654,11 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
viewportImageIds.length !== importedImageIds.length ||
!viewportImageIds.every((id, idx) => id === importedImageIds[idx])
) {
appLogger.warn(
`Viewport ${i}: importedImageIds do not match viewportImageIds`,
{ importedImageIds, viewportImageIds }
);
allReady = false;
break;
}
} catch (e) {
allReady = false;
}
}
if (!allReady && attempts < maxAttempts) {
@@ -5352,6 +5667,8 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
return;
}
let anyStackReloaded = false;
// First pass: set stacks/volumes and stack positions so viewports have image resources
for (let i = 0; i < numActive; i++) {
appLogger.debug("Setting stack/volume for viewport", i);
@@ -5359,17 +5676,20 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
if (!viewport) continue;
const stackIdx = state.stackIdx?.[i];
const stackObj = state.stacks?.[stackIdx];
const importedImageIds = stackObj?.i || [];
const importedImageIds = (stackObj?.i || []).map(rewriteImageId);
if (!importedImageIds.length) continue;
appLogger.debug("mode", state.modes?.[i]);
if (state.modes?.[i] === "volume") {
const volumeId = `myVolumeId_${i}`;
const hasActor = viewport.getActorUIDs().includes(volumeId);
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 {
if (viewport.getActorUIDs().includes(volumeId)) {
viewport.removeActors([volumeId]);
@@ -5385,25 +5705,40 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
} catch (e) {
appLogger.warn("Failed to create/load volume for viewport", i, e);
}
}
// Best-effort orientation restore
if (state.orientations && state.orientations[i] && typeof viewport.setOrientation === "function") {
try { viewport.setOrientation(state.orientations[i]); viewport.render(); } catch (e) { /* ignore */ }
}
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 {
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") {
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
if (anyStackReloaded) {
const readyDelayMs = 1500;
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
for (let i = 0; i < numActive; i++) {
@@ -5412,7 +5747,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
if (!viewport) continue;
const stackIdx = state.stackIdx?.[i];
const stackObj = state.stacks?.[stackIdx];
const importedImageIds = stackObj?.i || [];
const importedImageIds = (stackObj?.i || []).map(rewriteImageId);
if (!importedImageIds.length) continue;
// Restore properties with sanitization + retry
@@ -7450,6 +7785,285 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
×
</button>
</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 }}>
<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>
+13 -2
View File
@@ -176,13 +176,21 @@ const shouldMountContainer = (container: HTMLElement, options?: MountOptions): b
};
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 attrDebug = container.getAttribute("data-log-debug");
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;
if (attrLevel !== null) {
if (localSyncDebug === "true") {
minLevel = "debug";
} else if (urlDebug !== null) {
minLevel = parseDebugFlag(urlDebug);
} else if (attrLevel !== null) {
minLevel = parseLogLevel(attrLevel);
} else if (options?.logging?.minLevel) {
minLevel = options.logging.minLevel;
@@ -223,6 +231,9 @@ const mountContainer = (container: HTMLElement, imageStacks: () => Promise<any[]
annotationJson={container.getAttribute("data-annotationjson") || undefined}
viewerState={container.getAttribute("data-viewerstate") || undefined}
initialLoggerConfig={initialLoggerConfig}
multiuserRoom={container.getAttribute("data-multiuser-room") || undefined}
username={container.getAttribute("data-username") || undefined}
isStaff={container.getAttribute("data-is-staff") === "true"}
/>
);