510 lines
16 KiB
TypeScript
510 lines
16 KiB
TypeScript
import { useEffect, useRef, useCallback, useState } from "react"
|
||
import createImageIdsAndCacheMetaData from "./lib/createImageIdsAndCacheMetaData"
|
||
import {
|
||
RenderingEngine,
|
||
Enums,
|
||
} from "@cornerstonejs/core"
|
||
import { init as csRenderInit } from "@cornerstonejs/core"
|
||
import { init as csToolsInit, addTool, ToolGroupManager, Enums as csToolsEnums, PanTool, WindowLevelTool, StackScrollTool, ZoomTool, PlanarRotateTool } from "@cornerstonejs/tools"
|
||
import { LengthTool, ProbeTool, ArrowAnnotateTool, RectangleROITool, EllipticalROITool } from "@cornerstonejs/tools"
|
||
import { init as dicomImageLoaderInit } from "@cornerstonejs/dicom-image-loader"
|
||
import * as cornerstoneTools from '@cornerstonejs/tools';
|
||
import { StackScrollOutOfBoundsEvent } from 'core/src/types/EventTypes';
|
||
|
||
|
||
const { MouseBindings } = csToolsEnums;
|
||
|
||
const { IMAGE_RENDERED } = Enums.Events;
|
||
|
||
const toolGroupId = 'STACK_TOOL_GROUP_ID';
|
||
|
||
// Common CT presets (add more as needed)
|
||
const WINDOW_LEVEL_PRESETS = [
|
||
{ name: "Soft Tissue", center: 40, width: 400 },
|
||
{ name: "Lung", center: -600, width: 1500 },
|
||
{ name: "Bone", center: 300, width: 1500 },
|
||
{ name: "Brain", center: 40, width: 80 },
|
||
{ name: "Abdomen", center: 60, width: 400 },
|
||
]
|
||
|
||
// Tool options for dropdowns
|
||
const TOOL_OPTIONS = [
|
||
{ label: "Window/Level", value: WindowLevelTool.toolName },
|
||
{ label: "Pan", value: PanTool.toolName },
|
||
{ label: "Zoom", value: ZoomTool.toolName },
|
||
{ label: "Stack Scroll", value: StackScrollTool.toolName },
|
||
{ label: "Rotate", value: PlanarRotateTool.toolName },
|
||
{ label: "Length", value: LengthTool.toolName },
|
||
{ label: "Probe", value: ProbeTool.toolName },
|
||
{ label: "Arrow Annotate", value: ArrowAnnotateTool.toolName },
|
||
{ label: "Rectangle ROI", value: RectangleROITool.toolName },
|
||
{ label: "Ellipse ROI", value: EllipticalROITool.toolName },
|
||
]
|
||
|
||
// Mouse button labels
|
||
const MOUSE_BUTTONS = [
|
||
{ label: "Left", value: "Primary" },
|
||
{ label: "Middle", value: "Auxiliary" },
|
||
{ label: "Right", value: "Secondary" },
|
||
]
|
||
|
||
|
||
function App() {
|
||
const elementRef = useRef<HTMLDivElement>(null)
|
||
const viewportRef = useRef<any>(null) // Store the viewport instance
|
||
const running = useRef(false)
|
||
|
||
// State to control preset menu open/close
|
||
const [presetsOpen, setPresetsOpen] = useState(false);
|
||
|
||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||
|
||
// State for overlay info
|
||
const [imageInfo, setImageInfo] = useState({
|
||
index: 0,
|
||
total: 0,
|
||
windowCenter: "",
|
||
windowWidth: "",
|
||
})
|
||
|
||
// State for selected tool per mouse button
|
||
const [mouseToolBindings, setMouseToolBindings] = useState({
|
||
Primary: WindowLevelTool.toolName,
|
||
Auxiliary: PanTool.toolName,
|
||
Secondary: ZoomTool.toolName,
|
||
})
|
||
|
||
// Helper to apply all mouse tool bindings
|
||
const applyMouseToolBindings = useCallback(() => {
|
||
console.log("applyMouseToolBindings", mouseToolBindings)
|
||
const toolGroup = cornerstoneTools.ToolGroupManager.getToolGroup(toolGroupId)
|
||
if (toolGroup) {
|
||
// Set all tools to passive first
|
||
TOOL_OPTIONS.forEach(opt => {
|
||
toolGroup.setToolPassive(opt.value, { removeAllBindings: true })
|
||
})
|
||
// Re-apply all bindings for all mouse buttons (Primary, Auxiliary, Secondary)
|
||
Object.entries(mouseToolBindings).forEach(([btn, tool]) => {
|
||
console.log("button", btn, "setToolActive", tool)
|
||
toolGroup.setToolActive(tool, {
|
||
bindings: [{ mouseButton: csToolsEnums.MouseBindings[btn] }],
|
||
})
|
||
})
|
||
// Do NOT set wheel bindings here!
|
||
}
|
||
}, [mouseToolBindings])
|
||
|
||
const handleToolChange = (mouseButton: string, toolName: string) => {
|
||
setMouseToolBindings(prev => ({ ...prev, [mouseButton]: toolName }))
|
||
}
|
||
|
||
const updateOverlay = useCallback(() => {
|
||
const viewport = viewportRef.current
|
||
if (!viewport) return
|
||
|
||
// For StackViewport in Cornerstone3D v3:
|
||
const currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
|
||
? viewport.getCurrentImageIdIndex()
|
||
: 0
|
||
const imageIds = typeof viewport.getImageIds === "function"
|
||
? viewport.getImageIds()
|
||
: []
|
||
const total = imageIds.length
|
||
|
||
// Use getVOI() for current window center/width
|
||
let windowCenter = ""
|
||
let windowWidth = ""
|
||
if (typeof viewport.getProperties === "function") {
|
||
const props = viewport.getProperties()
|
||
if (props.voiRange) {
|
||
const { lower, upper } = props.voiRange
|
||
windowCenter = ((upper + lower) / 2).toFixed(0)
|
||
windowWidth = (upper - lower).toFixed(0)
|
||
}
|
||
}
|
||
|
||
setImageInfo({
|
||
index: currentIndex + 1,
|
||
total,
|
||
windowCenter,
|
||
windowWidth,
|
||
})
|
||
}, [])
|
||
useEffect(() => {
|
||
const setup = async () => {
|
||
if (running.current) {
|
||
return
|
||
}
|
||
running.current = true
|
||
|
||
await csRenderInit()
|
||
await csToolsInit()
|
||
dicomImageLoaderInit({ maxWebWorkers: 1 })
|
||
|
||
// Add tools to Cornerstone3D
|
||
cornerstoneTools.addTool(PanTool)
|
||
cornerstoneTools.addTool(WindowLevelTool)
|
||
cornerstoneTools.addTool(StackScrollTool)
|
||
cornerstoneTools.addTool(ZoomTool)
|
||
cornerstoneTools.addTool(PlanarRotateTool)
|
||
cornerstoneTools.addTool(LengthTool)
|
||
cornerstoneTools.addTool(ProbeTool)
|
||
cornerstoneTools.addTool(ArrowAnnotateTool)
|
||
cornerstoneTools.addTool(RectangleROITool)
|
||
cornerstoneTools.addTool(EllipticalROITool)
|
||
|
||
// Create tool group ONCE
|
||
let toolGroup = ToolGroupManager.getToolGroup(toolGroupId)
|
||
if (!toolGroup) {
|
||
toolGroup = ToolGroupManager.createToolGroup(toolGroupId)
|
||
toolGroup.addTool(WindowLevelTool.toolName)
|
||
toolGroup.addTool(PanTool.toolName)
|
||
toolGroup.addTool(ZoomTool.toolName)
|
||
toolGroup.addTool(StackScrollTool.toolName, { loop: false })
|
||
toolGroup.addTool(PlanarRotateTool.toolName)
|
||
toolGroup.addTool(LengthTool.toolName)
|
||
toolGroup.addTool(ProbeTool.toolName)
|
||
toolGroup.addTool(ArrowAnnotateTool.toolName)
|
||
toolGroup.addTool(RectangleROITool.toolName)
|
||
toolGroup.addTool(EllipticalROITool.toolName)
|
||
}
|
||
|
||
// The Stack Scroll mouse wheel is a tool using the `mouseWheelCallback`
|
||
// and needs to be registered against the 'Wheel' binding.
|
||
toolGroup.setToolActive(StackScrollTool.toolName, {
|
||
bindings: [
|
||
{
|
||
mouseButton: MouseBindings.Wheel, // Wheel Mouse
|
||
},
|
||
],
|
||
});
|
||
|
||
|
||
const imageIds = await createImageIdsAndCacheMetaData()
|
||
|
||
// Instantiate a rendering engine
|
||
const renderingEngineId = "myRenderingEngine"
|
||
const renderingEngine = new RenderingEngine(renderingEngineId)
|
||
const viewportId = "CT"
|
||
|
||
|
||
const viewportInput = {
|
||
viewportId,
|
||
type: Enums.ViewportType.STACK,
|
||
element: elementRef.current,
|
||
}
|
||
|
||
renderingEngine.enableElement(viewportInput)
|
||
|
||
// Set the tool group on the viewport
|
||
toolGroup.addViewport(viewportId, renderingEngineId);
|
||
|
||
// Get the stack viewport that was created
|
||
const viewport = renderingEngine.getViewport(viewportId);
|
||
viewportRef.current = viewport; // Save for later use
|
||
|
||
// Create a stack volume
|
||
viewport.setStack(imageIds)
|
||
|
||
cornerstoneTools.utilities.stackPrefetch.enable(viewport.element);
|
||
|
||
// Render the image
|
||
viewport.render()
|
||
viewport.element.addEventListener(Enums.Events.IMAGE_RENDERED, updateOverlay)
|
||
///viewport.element.addEventListener(Enums.Events.STACK_NEW_IMAGE, updateOverlay)
|
||
//viewport.element.addEventListener(Enums.Events.CAMERA_MODIFIED, updateOverlay)
|
||
//viewport.element.addEventListener(Enums.Events.PRESET_MODIFIED, updateOverlay)
|
||
|
||
// Initial overlay update
|
||
updateOverlay()
|
||
|
||
// Apply mouse tool bindings after setup
|
||
applyMouseToolBindings()
|
||
}
|
||
|
||
setup()
|
||
|
||
// Create a stack viewport
|
||
}, [elementRef, updateOverlay])
|
||
|
||
// Handler to reset the view (camera and window/level)
|
||
const handleResetView = useCallback(() => {
|
||
const viewport = viewportRef.current
|
||
if (viewport && typeof viewport.resetCamera === "function") {
|
||
viewport.resetCamera();
|
||
viewport.resetProperties(); // Resets window/level and other properties
|
||
viewport.render();
|
||
}
|
||
}, [])
|
||
|
||
// Double-click handler (reuse the same logic)
|
||
const handleDoubleClick = handleResetView
|
||
|
||
// Handler to apply a preset
|
||
const handleApplyPreset = useCallback((center: number, width: number) => {
|
||
const viewport = viewportRef.current
|
||
if (viewport && typeof viewport.setProperties === "function") {
|
||
const props = viewport.getProperties()
|
||
viewport.setProperties({
|
||
...props,
|
||
voiRange: {
|
||
lower: center - width / 2,
|
||
upper: center + width / 2,
|
||
},
|
||
})
|
||
viewport.render()
|
||
}
|
||
}, [])
|
||
useEffect(() => {
|
||
applyMouseToolBindings();
|
||
}, [mouseToolBindings, applyMouseToolBindings]);
|
||
|
||
// Close the menu when clicking outside
|
||
useEffect(() => {
|
||
if (!presetsOpen) return;
|
||
const handleClick = (e: MouseEvent) => {
|
||
const menu = document.querySelector(".preset-menu");
|
||
if (menu && !menu.contains(e.target as Node)) {
|
||
setPresetsOpen(false);
|
||
}
|
||
};
|
||
document.addEventListener("mousedown", handleClick);
|
||
return () => document.removeEventListener("mousedown", handleClick);
|
||
}, [presetsOpen]);
|
||
|
||
return (
|
||
<div style={{ position: "relative", width: "512px", height: "512px" }}>
|
||
<button
|
||
style={{
|
||
position: "absolute",
|
||
top: 8,
|
||
left: 8,
|
||
zIndex: 10,
|
||
padding: "6px 12px",
|
||
background: "#222",
|
||
color: "#fff",
|
||
border: "none",
|
||
borderRadius: "4px",
|
||
cursor: "pointer",
|
||
opacity: 0.85,
|
||
userSelect: "none", // <-- Prevents text selection
|
||
}}
|
||
onClick={handleResetView}
|
||
>
|
||
Reset View
|
||
</button>
|
||
<div
|
||
ref={elementRef}
|
||
onDoubleClick={handleDoubleClick}
|
||
onContextMenu={e => e.preventDefault()} // <-- Add this line
|
||
style={{
|
||
width: "100%",
|
||
height: "100%",
|
||
backgroundColor: "#000",
|
||
}}
|
||
></div>
|
||
{/* Overlay at bottom left */}
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
left: 8,
|
||
bottom: 8,
|
||
background: "rgba(0,0,0,0.7)",
|
||
color: "#fff",
|
||
padding: "6px 12px",
|
||
borderRadius: "4px",
|
||
fontSize: "14px",
|
||
zIndex: 10,
|
||
pointerEvents: "none",
|
||
minWidth: "120px",
|
||
userSelect: "none", // <-- Prevents text selection
|
||
}}
|
||
>
|
||
<div>
|
||
Image: {imageInfo.index} / {imageInfo.total}
|
||
</div>
|
||
<div>
|
||
WC: {imageInfo.windowCenter} WW: {imageInfo.windowWidth}
|
||
</div>
|
||
</div>
|
||
{/* Preset menu at bottom right, expands on click */}
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
right: 8,
|
||
bottom: 8,
|
||
zIndex: 10,
|
||
}}
|
||
>
|
||
<div
|
||
className="preset-menu"
|
||
style={{
|
||
background: "rgba(0,0,0,0.7)",
|
||
color: "#fff",
|
||
borderRadius: "4px",
|
||
fontSize: "14px",
|
||
minWidth: "120px",
|
||
overflow: "hidden",
|
||
cursor: "pointer",
|
||
position: "relative",
|
||
boxShadow: presetsOpen ? "0 2px 8px rgba(0,0,0,0.3)" : undefined,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
padding: "6px 12px",
|
||
fontWeight: "bold",
|
||
userSelect: "none",
|
||
}}
|
||
onClick={() => setPresetsOpen((open) => !open)}
|
||
>
|
||
Presets
|
||
<span style={{ float: "right", fontWeight: "normal" }}>
|
||
{presetsOpen ? "▲" : "▼"}
|
||
</span>
|
||
</div>
|
||
{presetsOpen && (
|
||
<div
|
||
className="preset-menu-content"
|
||
style={{
|
||
background: "rgba(0,0,0,0.95)",
|
||
borderRadius: "0 0 4px 4px",
|
||
padding: "6px 0 6px 0",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
zIndex: 11,
|
||
}}
|
||
>
|
||
{WINDOW_LEVEL_PRESETS.map((preset) => (
|
||
<button
|
||
key={preset.name}
|
||
style={{
|
||
display: "block",
|
||
width: "100%",
|
||
margin: "2px 0",
|
||
background: "#333",
|
||
color: "#fff",
|
||
border: "none",
|
||
borderRadius: "3px",
|
||
cursor: "pointer",
|
||
padding: "4px 0",
|
||
fontSize: "13px",
|
||
opacity: 0.9,
|
||
}}
|
||
onClick={() => {
|
||
handleApplyPreset(preset.center, preset.width);
|
||
setPresetsOpen(false);
|
||
}}
|
||
>
|
||
{preset.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* Settings button at top right */}
|
||
<button
|
||
style={{
|
||
position: "absolute",
|
||
top: 8,
|
||
right: 8,
|
||
zIndex: 20,
|
||
padding: "6px 12px",
|
||
background: "#222",
|
||
color: "#fff",
|
||
border: "none",
|
||
borderRadius: "4px",
|
||
cursor: "pointer",
|
||
opacity: 0.85,
|
||
userSelect: "none",
|
||
}}
|
||
onClick={() => setSettingsOpen(true)}
|
||
>
|
||
Settings
|
||
</button>
|
||
|
||
{/* Settings Modal (viewport-local, not fixed to page) */}
|
||
{settingsOpen && (
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
top: 0,
|
||
left: 0,
|
||
width: "100%",
|
||
height: "100%",
|
||
background: "rgba(0,0,0,0.4)",
|
||
zIndex: 1000,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
}}
|
||
onClick={() => setSettingsOpen(false)}
|
||
>
|
||
<div
|
||
style={{
|
||
background: "#222",
|
||
color: "#fff",
|
||
borderRadius: "8px",
|
||
minWidth: "320px",
|
||
minHeight: "180px",
|
||
padding: "24px",
|
||
boxShadow: "0 4px 24px rgba(0,0,0,0.4)",
|
||
position: "relative",
|
||
}}
|
||
onClick={e => e.stopPropagation()}
|
||
>
|
||
<div style={{ fontSize: "20px", marginBottom: "16px" }}>
|
||
Settings
|
||
</div>
|
||
{/* Settings content: Tool dropdowns */}
|
||
<div style={{ marginBottom: "24px" }}>
|
||
<div style={{ marginBottom: 12, fontWeight: "bold" }}>Mouse Button Tool Bindings</div>
|
||
{MOUSE_BUTTONS.map((btn) => (
|
||
<div key={btn.value} style={{ marginBottom: 10 }}>
|
||
<label style={{ marginRight: 8 }}>{btn.label} Button:</label>
|
||
<select
|
||
value={mouseToolBindings[btn.value]}
|
||
onChange={e => handleToolChange(btn.value, e.target.value)}
|
||
style={{
|
||
background: "#333",
|
||
color: "#fff",
|
||
border: "1px solid #444",
|
||
borderRadius: "4px",
|
||
padding: "4px 8px",
|
||
fontSize: "14px",
|
||
}}
|
||
>
|
||
{TOOL_OPTIONS.map(opt => (
|
||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button
|
||
style={{
|
||
position: "absolute",
|
||
top: 12,
|
||
right: 12,
|
||
background: "transparent",
|
||
color: "#fff",
|
||
border: "none",
|
||
fontSize: "20px",
|
||
cursor: "pointer",
|
||
}}
|
||
onClick={() => setSettingsOpen(false)}
|
||
aria-label="Close"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* ...rest of your UI... */}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default App
|