Refactor CT presets and enhance modality-specific preset options for improved functionality
This commit is contained in:
+284
-217
@@ -104,8 +104,8 @@ const { MouseBindings, KeyboardBindings } = csToolsEnums;
|
||||
const { IMAGE_RENDERED } = Enums.Events;
|
||||
|
||||
|
||||
// Common CT presets (add more as needed)
|
||||
const WINDOW_LEVEL_PRESETS = [
|
||||
// CT-specific presets. Other modalities use reset + autolevel only.
|
||||
const CT_WINDOW_LEVEL_PRESETS = [
|
||||
{ name: "Soft Tissue", center: 40, width: 400 },
|
||||
{ name: "Lung", center: -600, width: 1500 },
|
||||
{ name: "Bone", center: 300, width: 1500 },
|
||||
@@ -113,6 +113,32 @@ const WINDOW_LEVEL_PRESETS = [
|
||||
{ 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)
|
||||
const TOOL_OPTIONS = [
|
||||
{ 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 CTRL_MOUSE_SETTINGS_KEY = "dv3d_ctrlMouseToolBindings";
|
||||
|
||||
// State to control preset menu open/close
|
||||
const [presetsOpen, setPresetsOpen] = useState(false);
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
// Settings open state
|
||||
// (top bar removed; stack browser moved to right-side panel)
|
||||
|
||||
// Right-side stack panel open state (toggled by button)
|
||||
const [stackPanelOpen, setStackPanelOpen] = useState(false);
|
||||
const [showOpenButtonHover, setShowOpenButtonHover] = useState(false);
|
||||
const [stackPanelWidth, setStackPanelWidth] = useState(320);
|
||||
const stackPanelResizeRef = useRef<{ resizing: boolean; startX: number; startWidth: number }>({
|
||||
resizing: false,
|
||||
@@ -936,16 +958,23 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
||||
windowCenter: string,
|
||||
windowWidth: string,
|
||||
slicePosition: string,
|
||||
modality: string,
|
||||
}>>(() =>
|
||||
Array(viewportGrid.rows * viewportGrid.cols).fill({
|
||||
index: 0,
|
||||
total: 0,
|
||||
windowCenter: "",
|
||||
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'>>(
|
||||
() => Array(MAX_VIEWPORTS).fill('stack')
|
||||
);
|
||||
@@ -1584,6 +1613,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
||||
let windowCenter = "";
|
||||
let windowWidth = "";
|
||||
let slicePosition = "";
|
||||
let modality = "";
|
||||
|
||||
if (viewportModes[viewportIdx] === "stack") {
|
||||
currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
|
||||
@@ -1593,6 +1623,26 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
||||
? viewport.getImageIds()
|
||||
: [];
|
||||
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") {
|
||||
const props = viewport.getProperties();
|
||||
if (props.voiRange) {
|
||||
@@ -1632,6 +1682,12 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
||||
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 => {
|
||||
@@ -1642,13 +1698,30 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
||||
windowCenter,
|
||||
windowWidth,
|
||||
slicePosition, // add this field
|
||||
modality,
|
||||
};
|
||||
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() {
|
||||
for (let i = 0; i < viewportGrid.rows * viewportGrid.cols; i++) {
|
||||
@@ -2062,6 +2135,57 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
setImageInfos(Array(viewportGrid.rows * viewportGrid.cols).fill({
|
||||
@@ -2069,6 +2193,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
||||
total: 0,
|
||||
windowCenter: "",
|
||||
windowWidth: "",
|
||||
slicePosition: "",
|
||||
modality: "",
|
||||
}));
|
||||
setPresetsOpenArr(Array(viewportGrid.rows * viewportGrid.cols).fill(false));
|
||||
}, [viewportGrid]);
|
||||
@@ -2078,18 +2204,18 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
||||
applyMouseToolBindings()
|
||||
}, [mouseToolBindings, ctrlMouseToolBindings, ctrlPressed, applyMouseToolBindings])
|
||||
|
||||
// Close the menu when clicking outside
|
||||
// Close preset menus when clicking outside
|
||||
useEffect(() => {
|
||||
if (!presetsOpen) return;
|
||||
if (!presetsOpenArr.some(Boolean)) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const menu = document.querySelector(".preset-menu");
|
||||
if (menu && !menu.contains(e.target as Node)) {
|
||||
setPresetsOpen(false);
|
||||
setPresetsOpenArr(prev => prev.map(() => false));
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [presetsOpen]);
|
||||
}, [presetsOpenArr]);
|
||||
|
||||
useEffect(() => {
|
||||
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" }}>
|
||||
{presetsOpenArr[i] ? "▲" : "▼"}
|
||||
</span>
|
||||
@@ -3487,58 +3613,45 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
||||
}}
|
||||
onClick={e => e.stopPropagation()} // <-- prevent bubbling to viewport
|
||||
>
|
||||
<button
|
||||
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) => (
|
||||
{getPresetOptionsForModality(imageInfos[i]?.modality).map((preset) => (
|
||||
<button
|
||||
key={preset.name}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
margin: "2px 0",
|
||||
background: "#333",
|
||||
background: preset.action === "preset" ? "#333" : "#444",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "3px",
|
||||
cursor: "pointer",
|
||||
padding: "4px 0",
|
||||
padding: preset.action === "preset" ? "4px 0" : "6px 0",
|
||||
fontSize: "13px",
|
||||
opacity: 0.9,
|
||||
fontWeight: preset.action === "preset" ? "normal" : "600",
|
||||
}}
|
||||
onClick={e => {
|
||||
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 => {
|
||||
const next = [...prev];
|
||||
next[i] = false;
|
||||
@@ -3560,96 +3673,124 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
||||
position: "absolute",
|
||||
top: "10%",
|
||||
right: 8,
|
||||
width: "12px",
|
||||
width: "14px",
|
||||
height: "80%",
|
||||
maxHeight: "100%",
|
||||
minHeight: 0,
|
||||
zIndex: 30,
|
||||
zIndex: 35,
|
||||
display: viewportImageIds[i].length > 0 ? "flex" : "none",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
pointerEvents: "auto",
|
||||
flexDirection: "column",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "100%",
|
||||
maxHeight: "100%",
|
||||
minHeight: 0,
|
||||
borderRadius: "6px",
|
||||
background: "#222",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "flex-start",
|
||||
boxShadow: "0 0 8px #2196f3cc",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{viewportImageIds[i].map((id, idx) => {
|
||||
const loaded = !!metaData.get("imagePlaneModule", id);
|
||||
const renderingEngine = renderingEngineRef.current;
|
||||
let currentIdx = -1;
|
||||
if (
|
||||
renderingEngine &&
|
||||
typeof renderingEngine.getViewport === "function"
|
||||
) {
|
||||
const viewport = renderingEngine.getViewport(`CT_${i}`);
|
||||
if (viewport && typeof viewport.getCurrentImageIdIndex === "function") {
|
||||
currentIdx = viewport.getCurrentImageIdIndex();
|
||||
}
|
||||
}
|
||||
const isCurrent = idx === currentIdx;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: `${100 / viewportImageIds[i].length}%`,
|
||||
background: isCurrent
|
||||
? "linear-gradient(to right, #ffeb3b 60%, #ff9800 100%)"
|
||||
: loaded
|
||||
? "linear-gradient(to right, #4caf50 60%, #2196f3 100%)"
|
||||
: "#444",
|
||||
opacity: loaded ? 0.95 : 0.4,
|
||||
transition: "background 0.3s, opacity 0.3s",
|
||||
borderBottom:
|
||||
idx < viewportImageIds[i].length - 1
|
||||
? "1px solid #222"
|
||||
: "none",
|
||||
pointerEvents: "auto",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
title={
|
||||
isCurrent
|
||||
? `Current image #${idx + 1}`
|
||||
: loaded
|
||||
? `Go to image #${idx + 1}`
|
||||
: `Loading...`
|
||||
{(() => {
|
||||
const imageIds = viewportImageIds[i] || [];
|
||||
const total = imageIds.length;
|
||||
if (total === 0) return null;
|
||||
|
||||
const renderingEngine = renderingEngineRef.current;
|
||||
const viewport = renderingEngine?.getViewport(`CT_${i}`);
|
||||
const currentIdx = viewport && typeof viewport.getCurrentImageIdIndex === "function"
|
||||
? viewport.getCurrentImageIdIndex()
|
||||
: -1;
|
||||
|
||||
const viewedSet = new Set(viewedIndicesByViewport[i] || []);
|
||||
const loadedFlags = imageIds.map((id) => {
|
||||
if (id.startsWith("external:")) return true;
|
||||
return !!metaData.get("imagePlaneModule", id) || !!metaData.get("generalSeriesModule", id);
|
||||
});
|
||||
const loadedCount = loadedFlags.reduce((sum, loaded) => sum + (loaded ? 1 : 0), 0);
|
||||
const viewedCount = imageIds.reduce((sum, _id, idx) => sum + (viewedSet.has(idx) ? 1 : 0), 0);
|
||||
|
||||
const segmentCount = Math.min(total, 180);
|
||||
const segments = Array.from({ length: segmentCount }, () => ({ loaded: false, viewed: false, current: false }));
|
||||
|
||||
for (let idx = 0; idx < total; idx++) {
|
||||
const segmentIdx = total === 1
|
||||
? 0
|
||||
: Math.min(segmentCount - 1, Math.floor((idx / (total - 1)) * (segmentCount - 1)));
|
||||
if (loadedFlags[idx]) segments[segmentIdx].loaded = true;
|
||||
if (viewedSet.has(idx)) segments[segmentIdx].viewed = true;
|
||||
if (idx === currentIdx) segments[segmentIdx].current = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "10px",
|
||||
height: "100%",
|
||||
maxHeight: "100%",
|
||||
minHeight: 0,
|
||||
borderRadius: "6px",
|
||||
background: "#191919",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "flex-start",
|
||||
boxShadow: "0 0 8px #2196f3cc",
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
title={`Loaded ${loadedCount}/${total} • Viewed ${viewedCount}/${total}`}
|
||||
onClick={(e) => {
|
||||
const bar = e.currentTarget as HTMLDivElement;
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const y = Math.min(rect.height, Math.max(0, e.clientY - rect.top));
|
||||
const ratio = rect.height > 0 ? y / rect.height : 0;
|
||||
const targetIndex = Math.min(total - 1, Math.max(0, Math.round(ratio * (total - 1))));
|
||||
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}`);
|
||||
if (
|
||||
viewport &&
|
||||
typeof viewport.setImageIdIndex === "function" &&
|
||||
viewportModes[i] === "stack"
|
||||
) {
|
||||
}}
|
||||
>
|
||||
{segments.map((segment, idx) => {
|
||||
let background = "#3f3f3f";
|
||||
if (segment.loaded) background = "#2e7d32";
|
||||
if (segment.viewed) background = "#1565c0";
|
||||
if (segment.current) background = "#fbc02d";
|
||||
|
||||
//viewport.setImageIdIndex(idx);
|
||||
csUtilities.jumpToSlice(viewport.element, { imageIndex: idx })
|
||||
//viewport.render();
|
||||
updateOverlay(i, viewport);
|
||||
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
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",
|
||||
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>
|
||||
)}
|
||||
{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-edge hover strip to reveal open button */}
|
||||
<div
|
||||
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 && (
|
||||
{/* Open stacks button: compact and lower z-index so it does not cover modals/controls */}
|
||||
{!stackPanelOpen && (
|
||||
<button
|
||||
onClick={() => setStackPanelOpen(true)}
|
||||
onMouseEnter={() => setShowOpenButtonHover(true)}
|
||||
onMouseLeave={() => setShowOpenButtonHover(false)}
|
||||
aria-pressed={false}
|
||||
title="Open stacks"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 14,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 2700,
|
||||
width: 28,
|
||||
height: 48,
|
||||
right: 8,
|
||||
top: 76,
|
||||
zIndex: 120,
|
||||
width: 24,
|
||||
height: 42,
|
||||
background: '#1976d2',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
borderRadius: 5,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -4288,83 +4419,21 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
||||
className="grid-menu-hover-container"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
top: 8,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 100,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
width: "auto",
|
||||
height: "48px",
|
||||
pointerEvents: "none", // Only the hit area and button will be interactive
|
||||
height: "40px",
|
||||
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
|
||||
className="grid-menu-btn"
|
||||
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",
|
||||
@@ -4377,11 +4446,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
|
||||
pointerEvents: "auto",
|
||||
zIndex: 2,
|
||||
display: "block",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
onMouseEnter={() => setGridBtnActive(true)}
|
||||
onMouseLeave={() => setGridBtnActive(false)}
|
||||
onBlur={() => setGridBtnActive(false)}
|
||||
onClick={() => setViewportMenuOpen(open => !open)}
|
||||
tabIndex={0}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user