Refactor CT presets and enhance modality-specific preset options for improved functionality

This commit is contained in:
Ross
2026-05-18 10:48:28 +01:00
parent 1674254a3c
commit 19702b6052
+284 -217
View File
@@ -104,8 +104,8 @@ const { MouseBindings, KeyboardBindings } = csToolsEnums;
const { IMAGE_RENDERED } = Enums.Events; const { IMAGE_RENDERED } = Enums.Events;
// Common CT presets (add more as needed) // CT-specific presets. Other modalities use reset + autolevel only.
const WINDOW_LEVEL_PRESETS = [ const CT_WINDOW_LEVEL_PRESETS = [
{ name: "Soft Tissue", center: 40, width: 400 }, { name: "Soft Tissue", center: 40, width: 400 },
{ name: "Lung", center: -600, width: 1500 }, { name: "Lung", center: -600, width: 1500 },
{ name: "Bone", center: 300, width: 1500 }, { name: "Bone", center: 300, width: 1500 },
@@ -113,6 +113,32 @@ const WINDOW_LEVEL_PRESETS = [
{ name: "Abdomen", center: 60, width: 400 }, { name: "Abdomen", center: 60, width: 400 },
] ]
type PresetOption =
| { name: string; action: "reset" | "autolevel" }
| { name: string; action: "preset"; center: number; width: number };
const getPresetOptionsForModality = (modality?: string): PresetOption[] => {
const normalized = (modality || "").toUpperCase();
const common: PresetOption[] = [
{ name: "Reset to default", action: "reset" },
{ name: "Auto level", action: "autolevel" },
];
if (normalized !== "CT") {
return common;
}
return [
...common,
...CT_WINDOW_LEVEL_PRESETS.map((preset) => ({
name: preset.name,
action: "preset" as const,
center: preset.center,
width: preset.width,
})),
];
}
// Tool options for dropdowns (icons for compact UI) // Tool options for dropdowns (icons for compact UI)
const TOOL_OPTIONS = [ const TOOL_OPTIONS = [
{ label: "Window/Level", value: WindowLevelTool.toolName, icon: "🌓" }, { label: "Window/Level", value: WindowLevelTool.toolName, icon: "🌓" },
@@ -177,16 +203,12 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const MOUSE_SETTINGS_KEY = "dv3d_mouseToolBindings"; const MOUSE_SETTINGS_KEY = "dv3d_mouseToolBindings";
const CTRL_MOUSE_SETTINGS_KEY = "dv3d_ctrlMouseToolBindings"; const CTRL_MOUSE_SETTINGS_KEY = "dv3d_ctrlMouseToolBindings";
// State to control preset menu open/close
const [presetsOpen, setPresetsOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
// Settings open state // Settings open state
// (top bar removed; stack browser moved to right-side panel) // (top bar removed; stack browser moved to right-side panel)
// Right-side stack panel open state (toggled by button) // Right-side stack panel open state (toggled by button)
const [stackPanelOpen, setStackPanelOpen] = useState(false); const [stackPanelOpen, setStackPanelOpen] = useState(false);
const [showOpenButtonHover, setShowOpenButtonHover] = useState(false);
const [stackPanelWidth, setStackPanelWidth] = useState(320); const [stackPanelWidth, setStackPanelWidth] = useState(320);
const stackPanelResizeRef = useRef<{ resizing: boolean; startX: number; startWidth: number }>({ const stackPanelResizeRef = useRef<{ resizing: boolean; startX: number; startWidth: number }>({
resizing: false, resizing: false,
@@ -936,16 +958,23 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
windowCenter: string, windowCenter: string,
windowWidth: string, windowWidth: string,
slicePosition: string, slicePosition: string,
modality: string,
}>>(() => }>>(() =>
Array(viewportGrid.rows * viewportGrid.cols).fill({ Array(viewportGrid.rows * viewportGrid.cols).fill({
index: 0, index: 0,
total: 0, total: 0,
windowCenter: "", windowCenter: "",
windowWidth: "", windowWidth: "",
slicePosition: 0, slicePosition: "",
modality: "",
}) })
); );
const [viewedIndicesByViewport, setViewedIndicesByViewport] = useState<number[][]>(
() => Array(MAX_VIEWPORTS).fill(null).map(() => [])
);
const viewportStackSignaturesRef = useRef<string[]>(Array(MAX_VIEWPORTS).fill(""));
const [viewportModes, setViewportModes] = useState<Array<'stack' | 'volume'>>( const [viewportModes, setViewportModes] = useState<Array<'stack' | 'volume'>>(
() => Array(MAX_VIEWPORTS).fill('stack') () => Array(MAX_VIEWPORTS).fill('stack')
); );
@@ -1584,6 +1613,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
let windowCenter = ""; let windowCenter = "";
let windowWidth = ""; let windowWidth = "";
let slicePosition = ""; let slicePosition = "";
let modality = "";
if (viewportModes[viewportIdx] === "stack") { if (viewportModes[viewportIdx] === "stack") {
currentIndex = typeof viewport.getCurrentImageIdIndex === "function" currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
@@ -1593,6 +1623,26 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
? viewport.getImageIds() ? viewport.getImageIds()
: []; : [];
total = imageIds.length; total = imageIds.length;
const currentImageId = imageIds[currentIndex];
if (currentImageId) {
const seriesMeta = metaData.get("generalSeriesModule", currentImageId) as any;
modality = seriesMeta?.modality || "";
}
if (currentIndex >= 0) {
setViewedIndicesByViewport((prev) => {
const next = [...prev];
const existing = new Set(next[viewportIdx] || []);
if (!existing.has(currentIndex)) {
existing.add(currentIndex);
next[viewportIdx] = Array.from(existing).sort((a, b) => a - b);
return next;
}
return prev;
});
}
if (typeof viewport.getProperties === "function") { if (typeof viewport.getProperties === "function") {
const props = viewport.getProperties(); const props = viewport.getProperties();
if (props.voiRange) { if (props.voiRange) {
@@ -1632,6 +1682,12 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
slicePosition = coords.position.toFixed(2) + " mm"; slicePosition = coords.position.toFixed(2) + " mm";
} }
} }
const firstImageId = viewportImageIds[viewportIdx]?.[0];
if (firstImageId) {
const seriesMeta = metaData.get("generalSeriesModule", firstImageId) as any;
modality = seriesMeta?.modality || "";
}
} }
setImageInfos(prev => { setImageInfos(prev => {
@@ -1642,13 +1698,30 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
windowCenter, windowCenter,
windowWidth, windowWidth,
slicePosition, // add this field slicePosition, // add this field
modality,
}; };
return next; return next;
}); });
}, [viewportModes]); }, [viewportModes, viewportImageIds]);
useEffect(() => {
setViewedIndicesByViewport((prev) => {
let changed = false;
const next = [...prev];
const [gridBtnActive, setGridBtnActive] = useState(false); for (let i = 0; i < MAX_VIEWPORTS; i++) {
const ids = viewportImageIds[i] || [];
const signature = `${ids.length}:${ids[0] || ""}:${ids[ids.length - 1] || ""}`;
if (viewportStackSignaturesRef.current[i] !== signature) {
viewportStackSignaturesRef.current[i] = signature;
next[i] = [];
changed = true;
}
}
return changed ? next : prev;
});
}, [viewportImageIds]);
function saveVisibleViewportState() { function saveVisibleViewportState() {
for (let i = 0; i < viewportGrid.rows * viewportGrid.cols; i++) { for (let i = 0; i < viewportGrid.rows * viewportGrid.cols; i++) {
@@ -2062,6 +2135,57 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
} }
}, [updateOverlay]); }, [updateOverlay]);
const handleAutoLevel = useCallback((viewportIdx: number) => {
const viewportId = `CT_${viewportIdx}`;
try {
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) return;
const viewport = renderingEngine.getViewport(viewportId);
if (!viewport) return;
const ids = typeof viewport.getImageIds === "function"
? viewport.getImageIds()
: (viewportImageIds[viewportIdx] || []);
const currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
? viewport.getCurrentImageIdIndex()
: 0;
const imageId = ids[currentIndex] || ids[0];
if (!imageId) {
if (typeof viewport.resetProperties === "function") viewport.resetProperties();
viewport.render();
updateOverlay(viewportIdx, viewport);
return;
}
const pixelMeta = (metaData.get("imagePixelModule", imageId) || {}) as any;
const lutMeta = (metaData.get("modalityLutModule", imageId) || {}) as any;
const minRaw = Number(pixelMeta.smallestPixelValue ?? pixelMeta.smallestImagePixelValue);
const maxRaw = Number(pixelMeta.largestPixelValue ?? pixelMeta.largestImagePixelValue);
const slope = Number(lutMeta.rescaleSlope ?? 1);
const intercept = Number(lutMeta.rescaleIntercept ?? 0);
if (!Number.isFinite(minRaw) || !Number.isFinite(maxRaw) || maxRaw <= minRaw) {
if (typeof viewport.resetProperties === "function") viewport.resetProperties();
viewport.render();
updateOverlay(viewportIdx, viewport);
return;
}
viewport.setProperties({
voiRange: {
lower: minRaw * slope + intercept,
upper: maxRaw * slope + intercept,
},
});
viewport.render();
updateOverlay(viewportIdx, viewport);
} catch (e) {
// Ignore failures and keep existing VOI.
}
}, [updateOverlay, viewportImageIds]);
// Update overlay and preset state arrays when grid changes // Update overlay and preset state arrays when grid changes
useEffect(() => { useEffect(() => {
setImageInfos(Array(viewportGrid.rows * viewportGrid.cols).fill({ setImageInfos(Array(viewportGrid.rows * viewportGrid.cols).fill({
@@ -2069,6 +2193,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
total: 0, total: 0,
windowCenter: "", windowCenter: "",
windowWidth: "", windowWidth: "",
slicePosition: "",
modality: "",
})); }));
setPresetsOpenArr(Array(viewportGrid.rows * viewportGrid.cols).fill(false)); setPresetsOpenArr(Array(viewportGrid.rows * viewportGrid.cols).fill(false));
}, [viewportGrid]); }, [viewportGrid]);
@@ -2078,18 +2204,18 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
applyMouseToolBindings() applyMouseToolBindings()
}, [mouseToolBindings, ctrlMouseToolBindings, ctrlPressed, applyMouseToolBindings]) }, [mouseToolBindings, ctrlMouseToolBindings, ctrlPressed, applyMouseToolBindings])
// Close the menu when clicking outside // Close preset menus when clicking outside
useEffect(() => { useEffect(() => {
if (!presetsOpen) return; if (!presetsOpenArr.some(Boolean)) return;
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
const menu = document.querySelector(".preset-menu"); const menu = document.querySelector(".preset-menu");
if (menu && !menu.contains(e.target as Node)) { if (menu && !menu.contains(e.target as Node)) {
setPresetsOpen(false); setPresetsOpenArr(prev => prev.map(() => false));
} }
}; };
document.addEventListener("mousedown", handleClick); document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick);
}, [presetsOpen]); }, [presetsOpenArr]);
useEffect(() => { useEffect(() => {
const preventExtraButtonDefault = (e: MouseEvent) => { const preventExtraButtonDefault = (e: MouseEvent) => {
@@ -3468,7 +3594,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
}); });
}} }}
> >
Presets Presets ({imageInfos[i]?.modality || "Unknown"})
<span style={{ float: "right", fontWeight: "normal" }}> <span style={{ float: "right", fontWeight: "normal" }}>
{presetsOpenArr[i] ? "▲" : "▼"} {presetsOpenArr[i] ? "▲" : "▼"}
</span> </span>
@@ -3487,58 +3613,45 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
}} }}
onClick={e => e.stopPropagation()} // <-- prevent bubbling to viewport onClick={e => e.stopPropagation()} // <-- prevent bubbling to viewport
> >
<button {getPresetOptionsForModality(imageInfos[i]?.modality).map((preset) => (
style={{
display: "block",
width: "100%",
margin: "2px 0 8px 0",
background: "#444",
color: "#fff",
border: "none",
borderRadius: "3px",
cursor: "pointer",
padding: "6px 0",
fontSize: "13px",
fontWeight: "600",
}}
onClick={(e) => {
e.stopPropagation();
// Reset viewport properties (window/level and camera) to defaults
try {
const viewportId = `CT_${i}`;
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(viewportId);
if (viewport) {
if (typeof viewport.resetProperties === 'function') viewport.resetProperties();
if (typeof viewport.resetCamera === 'function') viewport.resetCamera();
if (typeof viewport.render === 'function') viewport.render();
updateOverlay(i, viewport);
}
} catch (err) { console.warn('Failed to reset viewport properties', err); }
setPresetsOpenArr(prev => { const next = [...prev]; next[i] = false; return next; });
}}
>
Reset to default
</button>
{WINDOW_LEVEL_PRESETS.map((preset) => (
<button <button
key={preset.name} key={preset.name}
style={{ style={{
display: "block", display: "block",
width: "100%", width: "100%",
margin: "2px 0", margin: "2px 0",
background: "#333", background: preset.action === "preset" ? "#333" : "#444",
color: "#fff", color: "#fff",
border: "none", border: "none",
borderRadius: "3px", borderRadius: "3px",
cursor: "pointer", cursor: "pointer",
padding: "4px 0", padding: preset.action === "preset" ? "4px 0" : "6px 0",
fontSize: "13px", fontSize: "13px",
opacity: 0.9, opacity: 0.9,
fontWeight: preset.action === "preset" ? "normal" : "600",
}} }}
onClick={e => { onClick={e => {
e.stopPropagation(); // <-- prevent bubbling to viewport e.stopPropagation(); // <-- prevent bubbling to viewport
handleApplyPreset(i, preset.center, preset.width); if (preset.action === "reset") {
try {
const viewportId = `CT_${i}`;
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(viewportId);
if (viewport) {
if (typeof viewport.resetProperties === "function") viewport.resetProperties();
if (typeof viewport.resetCamera === "function") viewport.resetCamera();
if (typeof viewport.render === "function") viewport.render();
updateOverlay(i, viewport);
}
} catch (err) {
console.warn("Failed to reset viewport properties", err);
}
} else if (preset.action === "autolevel") {
handleAutoLevel(i);
} else {
const ctPreset = preset as Extract<PresetOption, { action: "preset" }>;
handleApplyPreset(i, ctPreset.center, ctPreset.width);
}
setPresetsOpenArr(prev => { setPresetsOpenArr(prev => {
const next = [...prev]; const next = [...prev];
next[i] = false; next[i] = false;
@@ -3560,96 +3673,124 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
position: "absolute", position: "absolute",
top: "10%", top: "10%",
right: 8, right: 8,
width: "12px", width: "14px",
height: "80%", height: "80%",
maxHeight: "100%", maxHeight: "100%",
minHeight: 0, minHeight: 0,
zIndex: 30, zIndex: 35,
display: viewportImageIds[i].length > 0 ? "flex" : "none", display: viewportImageIds[i].length > 0 ? "flex" : "none",
alignItems: "flex-start", alignItems: "flex-start",
justifyContent: "center", justifyContent: "center",
pointerEvents: "none", pointerEvents: "auto",
flexDirection: "column", flexDirection: "column",
padding: 0, padding: 0,
}} }}
> >
<div {(() => {
style={{ const imageIds = viewportImageIds[i] || [];
width: "8px", const total = imageIds.length;
height: "100%", if (total === 0) return null;
maxHeight: "100%",
minHeight: 0, const renderingEngine = renderingEngineRef.current;
borderRadius: "6px", const viewport = renderingEngine?.getViewport(`CT_${i}`);
background: "#222", const currentIdx = viewport && typeof viewport.getCurrentImageIdIndex === "function"
position: "relative", ? viewport.getCurrentImageIdIndex()
display: "flex", : -1;
flexDirection: "column",
justifyContent: "flex-start", const viewedSet = new Set(viewedIndicesByViewport[i] || []);
boxShadow: "0 0 8px #2196f3cc", const loadedFlags = imageIds.map((id) => {
overflow: "hidden", if (id.startsWith("external:")) return true;
}} return !!metaData.get("imagePlaneModule", id) || !!metaData.get("generalSeriesModule", id);
> });
{viewportImageIds[i].map((id, idx) => { const loadedCount = loadedFlags.reduce((sum, loaded) => sum + (loaded ? 1 : 0), 0);
const loaded = !!metaData.get("imagePlaneModule", id); const viewedCount = imageIds.reduce((sum, _id, idx) => sum + (viewedSet.has(idx) ? 1 : 0), 0);
const renderingEngine = renderingEngineRef.current;
let currentIdx = -1; const segmentCount = Math.min(total, 180);
if ( const segments = Array.from({ length: segmentCount }, () => ({ loaded: false, viewed: false, current: false }));
renderingEngine &&
typeof renderingEngine.getViewport === "function" for (let idx = 0; idx < total; idx++) {
) { const segmentIdx = total === 1
const viewport = renderingEngine.getViewport(`CT_${i}`); ? 0
if (viewport && typeof viewport.getCurrentImageIdIndex === "function") { : Math.min(segmentCount - 1, Math.floor((idx / (total - 1)) * (segmentCount - 1)));
currentIdx = viewport.getCurrentImageIdIndex(); if (loadedFlags[idx]) segments[segmentIdx].loaded = true;
} if (viewedSet.has(idx)) segments[segmentIdx].viewed = true;
} if (idx === currentIdx) segments[segmentIdx].current = true;
const isCurrent = idx === currentIdx; }
return (
<div return (
key={id} <div
style={{ style={{
width: "100%", width: "10px",
height: `${100 / viewportImageIds[i].length}%`, height: "100%",
background: isCurrent maxHeight: "100%",
? "linear-gradient(to right, #ffeb3b 60%, #ff9800 100%)" minHeight: 0,
: loaded borderRadius: "6px",
? "linear-gradient(to right, #4caf50 60%, #2196f3 100%)" background: "#191919",
: "#444", position: "relative",
opacity: loaded ? 0.95 : 0.4, display: "flex",
transition: "background 0.3s, opacity 0.3s", flexDirection: "column",
borderBottom: justifyContent: "flex-start",
idx < viewportImageIds[i].length - 1 boxShadow: "0 0 8px #2196f3cc",
? "1px solid #222" overflow: "hidden",
: "none", cursor: "pointer",
pointerEvents: "auto", }}
cursor: "pointer", title={`Loaded ${loadedCount}/${total} • Viewed ${viewedCount}/${total}`}
}} onClick={(e) => {
title={ const bar = e.currentTarget as HTMLDivElement;
isCurrent const rect = bar.getBoundingClientRect();
? `Current image #${idx + 1}` const y = Math.min(rect.height, Math.max(0, e.clientY - rect.top));
: loaded const ratio = rect.height > 0 ? y / rect.height : 0;
? `Go to image #${idx + 1}` const targetIndex = Math.min(total - 1, Math.max(0, Math.round(ratio * (total - 1))));
: `Loading...` const targetViewport = renderingEngineRef.current?.getViewport(`CT_${i}`);
if (
targetViewport &&
typeof targetViewport.setImageIdIndex === "function" &&
viewportModes[i] === "stack"
) {
csUtilities.jumpToSlice(targetViewport.element, { imageIndex: targetIndex });
updateOverlay(i, targetViewport);
} }
onClick={() => { }}
const renderingEngine = renderingEngineRef.current; >
const viewport = renderingEngine?.getViewport(`CT_${i}`); {segments.map((segment, idx) => {
if ( let background = "#3f3f3f";
viewport && if (segment.loaded) background = "#2e7d32";
typeof viewport.setImageIdIndex === "function" && if (segment.viewed) background = "#1565c0";
viewportModes[i] === "stack" if (segment.current) background = "#fbc02d";
) {
//viewport.setImageIdIndex(idx); return (
csUtilities.jumpToSlice(viewport.element, { imageIndex: idx }) <div
//viewport.render(); key={idx}
updateOverlay(i, viewport); style={{
width: "100%",
} height: `${100 / segmentCount}%`,
}} minHeight: 1,
/> background,
); opacity: segment.loaded || segment.viewed || segment.current ? 0.95 : 0.45,
})} borderBottom: idx < segmentCount - 1 ? "1px solid #222" : "none",
</div> pointerEvents: "none",
}}
/>
);
})}
{currentIdx >= 0 && total > segmentCount && (
<div
style={{
position: "absolute",
left: 0,
right: 0,
top: `${(currentIdx / Math.max(1, total - 1)) * 100}%`,
height: 2,
background: "#ffeb3b",
boxShadow: "0 0 4px #ffeb3b",
transform: "translateY(-1px)",
pointerEvents: "none",
}}
/>
)}
</div>
);
})()}
</div> </div>
)} )}
{viewportModes[i] === "volume" && (() => { {viewportModes[i] === "volume" && (() => {
@@ -4012,33 +4153,23 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
{/* Right-side stack panel is implemented in the main content flex container below */} {/* Right-side stack panel is implemented in the main content flex container below */}
{/* Right-edge hover strip to reveal open button */} {/* Open stacks button: compact and lower z-index so it does not cover modals/controls */}
<div {!stackPanelOpen && (
onMouseEnter={() => { if (!stackPanelOpen) setShowOpenButtonHover(true); }}
onMouseLeave={() => { if (!stackPanelOpen) setShowOpenButtonHover(false); }}
style={{ position: 'absolute', right: 0, top: 0, height: '100%', width: 56, zIndex: 2400, pointerEvents: stackPanelOpen ? 'none' : 'auto' }}
/>
{/* Open button only visible when hover strip is active */}
{showOpenButtonHover && !stackPanelOpen && (
<button <button
onClick={() => setStackPanelOpen(true)} onClick={() => setStackPanelOpen(true)}
onMouseEnter={() => setShowOpenButtonHover(true)}
onMouseLeave={() => setShowOpenButtonHover(false)}
aria-pressed={false} aria-pressed={false}
title="Open stacks" title="Open stacks"
style={{ style={{
position: 'absolute', position: 'absolute',
right: 14, right: 8,
top: '50%', top: 76,
transform: 'translateY(-50%)', zIndex: 120,
zIndex: 2700, width: 24,
width: 28, height: 42,
height: 48,
background: '#1976d2', background: '#1976d2',
color: '#fff', color: '#fff',
border: 'none', border: 'none',
borderRadius: 6, borderRadius: 5,
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -4288,83 +4419,21 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
className="grid-menu-hover-container" className="grid-menu-hover-container"
style={{ style={{
position: "absolute", position: "absolute",
top: 0, top: 8,
left: "50%", left: "50%",
transform: "translateX(-50%)", transform: "translateX(-50%)",
zIndex: 100, zIndex: 100,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "row",
alignItems: "center", alignItems: "center",
width: "auto", width: "auto",
height: "48px", height: "40px",
pointerEvents: "none", // Only the hit area and button will be interactive pointerEvents: "auto",
}} }}
> >
{/* Transparent hit area to catch hover/focus */}
<div
style={{
position: "absolute",
top: 0,
left: "50%",
transform: "translateX(-50%)",
width: 80,
height: 14,
zIndex: 1,
pointerEvents: "auto",
background: "transparent",
}}
onMouseEnter={() => setGridBtnActive(true)}
onFocus={() => setGridBtnActive(true)}
tabIndex={-1}
/>
{/* --- Fullscreen button next to grid button --- */}
<button
style={{
position: "relative",
top: gridBtnActive ? "0" : "-43px",
opacity: gridBtnActive ? 1 : 0.5,
transition: "top 0.25s cubic-bezier(.4,2,.6,1), opacity 0.2s",
padding: "6px 16px",
background: "#222",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginLeft: "4px",
marginRight: "8px",
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
pointerEvents: "auto",
zIndex: 2,
display: "block",
}}
title="Toggle Fullscreen"
onMouseEnter={() => setGridBtnActive(true)}
onMouseLeave={() => setGridBtnActive(false)}
onClick={() => {
const root = appRootRef.current;
if (!root) return;
if (document.fullscreenElement === root) {
document.exitFullscreen();
} else if (root.requestFullscreen) {
root.requestFullscreen();
} else if ((root as any).webkitRequestFullscreen) {
(root as any).webkitRequestFullscreen();
} else if ((root as any).msRequestFullscreen) {
(root as any).msRequestFullscreen();
}
}}
>
Fullscreen
</button>
<button <button
className="grid-menu-btn" className="grid-menu-btn"
style={{ style={{
position: "relative",
top: gridBtnActive ? "0" : "-43px",
opacity: gridBtnActive ? 1 : 0.5,
transition: "top 0.25s cubic-bezier(.4,2,.6,1), opacity 0.2s",
padding: "6px 16px", padding: "6px 16px",
background: "#222", background: "#222",
color: "#fff", color: "#fff",
@@ -4377,11 +4446,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
boxShadow: "0 2px 8px rgba(0,0,0,0.2)", boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
pointerEvents: "auto", pointerEvents: "auto",
zIndex: 2, zIndex: 2,
display: "block", display: "inline-flex",
alignItems: "center",
}} }}
onMouseEnter={() => setGridBtnActive(true)}
onMouseLeave={() => setGridBtnActive(false)}
onBlur={() => setGridBtnActive(false)}
onClick={() => setViewportMenuOpen(open => !open)} onClick={() => setViewportMenuOpen(open => !open)}
tabIndex={0} tabIndex={0}
> >