From e0e40230536ad2aeae6ede679fb2c200d93464c1 Mon Sep 17 00:00:00 2001 From: Adithyan Dinesh Date: Mon, 19 Aug 2024 12:30:00 +0530 Subject: [PATCH] Created a SmartScrollbar component to show cached slices in each displayset and added an extra feature to prevent scrolling to uncached slices. --- .../Viewport/Overlays/CornerstoneOverlays.tsx | 3 +- .../Overlays/CustomizableViewportOverlay.css | 1 + extensions/cornerstone/src/commandsModule.ts | 14 +- extensions/cornerstone/src/index.tsx | 3 + .../cornerstone/src/initCornerstoneTools.js | 6 + .../ToolGroupService/ToolGroupService.ts | 4 +- .../tools/SmartStackScrollMouseWheelTool.ts | 29 +++ .../src/tools/SmartStackScrollTool.ts | 32 +++ .../src/utils/shouldPreventScroll.ts | 18 ++ extensions/default/src/init.ts | 3 + modes/segmentation/src/initToolGroups.ts | 16 +- modes/segmentation/src/toolbarButtons.ts | 2 +- platform/core/src/defaults/hotkeyBindings.js | 16 +- .../ImageScrollbar/ImageScrollbar.css | 1 + .../SmartScrollbar/SmartScrollbar.tsx | 230 ++++++++++++++++++ .../ui/src/components/SmartScrollbar/index.js | 2 + platform/ui/src/components/index.js | 2 + platform/ui/src/index.js | 1 + 18 files changed, 374 insertions(+), 9 deletions(-) create mode 100644 extensions/cornerstone/src/tools/SmartStackScrollMouseWheelTool.ts create mode 100644 extensions/cornerstone/src/tools/SmartStackScrollTool.ts create mode 100644 extensions/cornerstone/src/utils/shouldPreventScroll.ts create mode 100644 platform/ui/src/components/SmartScrollbar/SmartScrollbar.tsx create mode 100644 platform/ui/src/components/SmartScrollbar/index.js diff --git a/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx b/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx index 69f7b10faa2..2e52ea5f741 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { SmartScrollbar } from '@ohif/ui'; import ViewportImageScrollbar from './ViewportImageScrollbar'; import CustomizableViewportOverlay from './CustomizableViewportOverlay'; @@ -45,7 +46,7 @@ function CornerstoneOverlays(props) { return (
- { + scroll: ({ direction, isSmartScrolling = false }) => { const enabledElement = _getActiveViewportEnabledElement(); if (!enabledElement) { @@ -551,6 +553,16 @@ function commandsModule({ const { viewport } = enabledElement; const options = { delta: direction }; + if ( + shouldPreventScroll( + !isSmartScrolling, + viewport.getCurrentImageIdIndex() + direction, + servicesManager + ) + ) { + return; + } + cstUtils.scroll(viewport, options); }, setViewportColormap: ({ viewportId, displaySetInstanceUID, colormap, immediate = false }) => { diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index ac53c8f0f27..134b1ad9f6f 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -30,6 +30,7 @@ import * as csWADOImageLoader from './initWADOImageLoader.js'; import { measurementMappingUtils } from './utils/measurementServiceMappings'; import type { PublicViewportOptions } from './services/ViewportService/Viewport'; import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool'; +import shouldPreventScroll from './utils/shouldPreventScroll'; const Component = React.lazy(() => { return import(/* webpackPrefetch: true */ './Viewport/OHIFCornerstoneViewport'); @@ -129,6 +130,8 @@ const cornerstoneExtension: Types.Extensions.Extension = { exports: { toolNames, Enums: cs3DToolsEnums, + shouldPreventScroll: (keyPressed, imageIdIndex) => + shouldPreventScroll(keyPressed, imageIdIndex, servicesManager), }, }, ]; diff --git a/extensions/cornerstone/src/initCornerstoneTools.js b/extensions/cornerstone/src/initCornerstoneTools.js index 767b607a772..24929506723 100644 --- a/extensions/cornerstone/src/initCornerstoneTools.js +++ b/extensions/cornerstone/src/initCornerstoneTools.js @@ -32,6 +32,8 @@ import { import CalibrationLineTool from './tools/CalibrationLineTool'; import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool'; +import SmartStackScrollMouseWheelTool from './tools/SmartStackScrollMouseWheelTool'; +import SmartStackScrollTool from './tools/SmartStackScrollTool'; export default function initCornerstoneTools(configuration = {}) { CrosshairsTool.isAnnotation = false; @@ -66,6 +68,8 @@ export default function initCornerstoneTools(configuration = {}) { addTool(RectangleScissorsTool); addTool(SphereScissorsTool); addTool(ImageOverlayViewerTool); + addTool(SmartStackScrollMouseWheelTool); + addTool(SmartStackScrollTool); // Modify annotation tools to use dashed lines on SR const annotationStyle = { @@ -111,6 +115,8 @@ const toolNames = { RectangleScissors: RectangleScissorsTool.toolName, SphereScissors: SphereScissorsTool.toolName, ImageOverlayViewer: ImageOverlayViewerTool.toolName, + SmartStackScrollMouseWheel: SmartStackScrollMouseWheelTool.toolName, + SmartStackScroll: SmartStackScrollTool.toolName, }; export { toolNames }; diff --git a/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts b/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts index 3a85342b381..8050a877554 100644 --- a/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts +++ b/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts @@ -245,8 +245,8 @@ export default class ToolGroupService { } if (passive) { - passive.forEach(({ toolName }) => { - toolGroup.setToolPassive(toolName); + passive.forEach(({ toolName, bindings }) => { + toolGroup.setToolPassive(toolName, { bindings }); }); } diff --git a/extensions/cornerstone/src/tools/SmartStackScrollMouseWheelTool.ts b/extensions/cornerstone/src/tools/SmartStackScrollMouseWheelTool.ts new file mode 100644 index 00000000000..58e91d845b9 --- /dev/null +++ b/extensions/cornerstone/src/tools/SmartStackScrollMouseWheelTool.ts @@ -0,0 +1,29 @@ +import { getEnabledElement } from '@cornerstonejs/core'; +import { StackScrollMouseWheelTool, Types } from '@cornerstonejs/tools'; + +class SmartStackScrollMouseWheelTool extends StackScrollMouseWheelTool { + parentMouseWheelCallback: (evt: Types.EventTypes.MouseWheelEventType) => void; + + constructor(toolProps, defaultToolProps) { + super(toolProps, defaultToolProps); + this.parentMouseWheelCallback = this.mouseWheelCallback; + this.mouseWheelCallback = this.smartMouseWheelCallback; + } + + smartMouseWheelCallback(evt: Types.EventTypes.MouseWheelEventType): void { + const { wheel, element } = evt.detail; + const { direction } = wheel; + const { invert, shouldPreventScroll } = this.configuration; + const { viewport } = getEnabledElement(element); + const delta = direction * (invert ? -1 : 1); + + if (shouldPreventScroll(evt.detail.event.ctrlKey, viewport.getCurrentImageIdIndex() + delta)) { + return; + } + + this.parentMouseWheelCallback(evt); + } +} + +SmartStackScrollMouseWheelTool.toolName = 'SmartStackScrollMouseWheel'; +export default SmartStackScrollMouseWheelTool; diff --git a/extensions/cornerstone/src/tools/SmartStackScrollTool.ts b/extensions/cornerstone/src/tools/SmartStackScrollTool.ts new file mode 100644 index 00000000000..d94bf62e115 --- /dev/null +++ b/extensions/cornerstone/src/tools/SmartStackScrollTool.ts @@ -0,0 +1,32 @@ +import { getEnabledElementByIds } from '@cornerstonejs/core'; +import { StackScrollTool, Types } from '@cornerstonejs/tools'; + +class SmartStackScrollTool extends StackScrollTool { + parentDragCallback: (evt: Types.EventTypes.InteractionEventType) => void; + + constructor(toolProps, defaultToolProps) { + super(toolProps, defaultToolProps); + this.parentDragCallback = this._dragCallback; + this._dragCallback = this._smartDragCallback; + } + + _smartDragCallback(evt: Types.EventTypes.InteractionEventType) { + const { deltaPoints, viewportId, renderingEngineId } = evt.detail; + const { viewport } = getEnabledElementByIds(viewportId, renderingEngineId); + const { invert, shouldPreventScroll } = this.configuration; + const deltaPointY = deltaPoints.canvas[1]; + const pixelsPerImage = this._getPixelPerImage(viewport); + const deltaY = deltaPointY + this.deltaY; + const imageIdIndexOffset = Math.round(deltaY / pixelsPerImage); + const delta = invert ? -imageIdIndexOffset : imageIdIndexOffset; + + if (shouldPreventScroll(evt.detail.event.ctrlKey, viewport.getCurrentImageIdIndex() + delta)) { + return; + } + + return this.parentDragCallback(evt); + } +} + +SmartStackScrollTool.toolName = 'SmartStackScroll'; +export default SmartStackScrollTool; diff --git a/extensions/cornerstone/src/utils/shouldPreventScroll.ts b/extensions/cornerstone/src/utils/shouldPreventScroll.ts new file mode 100644 index 00000000000..22c5a25e8a2 --- /dev/null +++ b/extensions/cornerstone/src/utils/shouldPreventScroll.ts @@ -0,0 +1,18 @@ +export default function shouldPreventScroll( + keyPressed: boolean, + imageIdIndex: number, + servicesManager +): boolean { + const { stateSyncService, viewportGridService } = servicesManager.services; + const { cachedSlicesPerSeries } = stateSyncService.getState(); + const { activeViewportId, viewports } = viewportGridService.getState(); + const cachedSlices = cachedSlicesPerSeries[ + viewports.get(activeViewportId).displaySetInstanceUIDs[0] + ] as number[]; + + if (!cachedSlices) { + return false; + } + + return !keyPressed && !cachedSlices.includes(imageIdIndex); +} diff --git a/extensions/default/src/init.ts b/extensions/default/src/init.ts index 1dfc7de259b..53efdc33131 100644 --- a/extensions/default/src/init.ts +++ b/extensions/default/src/init.ts @@ -46,6 +46,9 @@ export default function init({ servicesManager, configuration = {} }): void { // changes numRows and numCols, the viewports can be remembers and then replaced // afterwards. stateSyncService.register('viewportsByPosition', { clearOnModeExit: true }); + + // Stores the cached frames of each series so that we can prevent scrolling to a slice that is not cached + stateSyncService.register('cachedSlicesPerSeries', { clearOnModeExit: true }); } const handlePETImageMetadata = ({ SeriesInstanceUID, StudyInstanceUID }) => { diff --git a/modes/segmentation/src/initToolGroups.ts b/modes/segmentation/src/initToolGroups.ts index 58204839ee4..3ea47568da7 100644 --- a/modes/segmentation/src/initToolGroups.ts +++ b/modes/segmentation/src/initToolGroups.ts @@ -17,13 +17,17 @@ const brushStrategies = { }; function createTools(utilityModule) { - const { toolNames, Enums } = utilityModule.exports; + const { toolNames, Enums, shouldPreventScroll } = utilityModule.exports; return { active: [ { toolName: toolNames.WindowLevel, bindings: [{ mouseButton: Enums.MouseBindings.Primary }] }, { toolName: toolNames.Pan, bindings: [{ mouseButton: Enums.MouseBindings.Auxiliary }] }, { toolName: toolNames.Zoom, bindings: [{ mouseButton: Enums.MouseBindings.Secondary }] }, - { toolName: toolNames.StackScrollMouseWheel, bindings: [] }, + { + toolName: toolNames.SmartStackScrollMouseWheel, + bindings: [], + configuration: { shouldPreventScroll }, + }, ], passive: Object.keys(brushInstanceNames) .map(brushName => ({ @@ -37,7 +41,13 @@ function createTools(utilityModule) { { toolName: toolNames.CircleScissors }, { toolName: toolNames.RectangleScissors }, { toolName: toolNames.SphereScissors }, - { toolName: toolNames.StackScroll }, + { + toolName: toolNames.SmartStackScroll, + bindings: [ + { mouseButton: Enums.MouseBindings.Primary, modifierKey: Enums.KeyboardBindings.Ctrl }, + ], + configuration: { shouldPreventScroll }, + }, { toolName: toolNames.Magnify }, { toolName: toolNames.SegmentationDisplay }, ]), diff --git a/modes/segmentation/src/toolbarButtons.ts b/modes/segmentation/src/toolbarButtons.ts index b17a33deae1..d1bf888b4be 100644 --- a/modes/segmentation/src/toolbarButtons.ts +++ b/modes/segmentation/src/toolbarButtons.ts @@ -288,7 +288,7 @@ const toolbarButtons = [ { commandName: 'setToolActive', commandOptions: { - toolName: 'StackScroll', + toolName: 'SmartStackScroll', }, context: 'CORNERSTONE', }, diff --git a/platform/core/src/defaults/hotkeyBindings.js b/platform/core/src/defaults/hotkeyBindings.js index c4b572b7049..b99aef00ef4 100644 --- a/platform/core/src/defaults/hotkeyBindings.js +++ b/platform/core/src/defaults/hotkeyBindings.js @@ -111,12 +111,26 @@ const bindings = [ { commandName: 'nextImage', label: 'Next Image', - keys: ['down'], + keys: ['ctrl+down'], isEditable: true, }, { commandName: 'previousImage', label: 'Previous Image', + keys: ['ctrl+up'], + isEditable: true, + }, + { + commandName: 'nextImage', + commandOptions: { isSmartScrolling: true }, + label: 'Smart Next Image', + keys: ['down'], + isEditable: true, + }, + { + commandName: 'previousImage', + commandOptions: { isSmartScrolling: true }, + label: 'Smart Previous Image', keys: ['up'], isEditable: true, }, diff --git a/platform/ui/src/components/ImageScrollbar/ImageScrollbar.css b/platform/ui/src/components/ImageScrollbar/ImageScrollbar.css index 6d1aaf32d05..e59c4f698a2 100644 --- a/platform/ui/src/components/ImageScrollbar/ImageScrollbar.css +++ b/platform/ui/src/components/ImageScrollbar/ImageScrollbar.css @@ -1,6 +1,7 @@ .scroll { height: 100%; padding: 5px; + padding-left: 0; position: absolute; right: 0; top: 0; diff --git a/platform/ui/src/components/SmartScrollbar/SmartScrollbar.tsx b/platform/ui/src/components/SmartScrollbar/SmartScrollbar.tsx new file mode 100644 index 00000000000..97c80bcab56 --- /dev/null +++ b/platform/ui/src/components/SmartScrollbar/SmartScrollbar.tsx @@ -0,0 +1,230 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { Enums, Types, utilities, eventTarget, cache } from '@cornerstonejs/core'; +import { utilities as csToolsUtils } from '@cornerstonejs/tools'; +import { ImageScrollbar } from '@ohif/ui'; +import { ServicesManger } from '@ohif/core'; + +const KEYS = { Ctrl: 17 }; + +function SmartImageScrollbar({ + viewportData, + viewportId, + element, + imageSliceData, + setImageSliceData, + scrollbarHeight, + servicesManager, +}) { + const [cachedSlices, setCachedSlices] = useState([]); + const [isKeyPressed, setIsKeyPressed] = useState(false); + + const { cineService, cornerstoneViewportService, stateSyncService } = ( + servicesManager as ServicesManger + ).services; + const numOfSlices = imageSliceData.numberOfSlices; + const scrollbarHeightValue = scrollbarHeight.split('px')[0]; + + const onImageScrollbarChange = (imageIndex, viewportId) => { + if (!isKeyPressed && !cachedSlices.includes(imageIndex)) { + return; + } + + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + + const { isCineEnabled } = cineService.getState(); + + if (isCineEnabled) { + // on image scrollbar change, stop the CINE if it is playing + cineService.stopClip(element); + cineService.setCine({ id: viewportId, isPlaying: false }); + } + + csToolsUtils.jumpToSlice(viewport.element, { + imageIndex, + debounceLoading: true, + }); + }; + + useEffect(() => { + if (!viewportData) { + return; + } + + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + + if (!viewport) { + return; + } + + if (viewportData.viewportType === Enums.ViewportType.STACK) { + const imageIndex = viewport.getCurrentImageIdIndex(); + + setImageSliceData({ + imageIndex: imageIndex, + numberOfSlices: viewportData.data.imageIds.length, + }); + + return; + } + + if (viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC) { + const sliceData = utilities.getImageSliceDataForVolumeViewport( + viewport as Types.IVolumeViewport + ); + + if (!sliceData) { + return; + } + + const { imageIndex, numberOfSlices } = sliceData; + setImageSliceData({ imageIndex, numberOfSlices }); + } + }, [viewportId, viewportData]); + + useEffect(() => { + if (viewportData?.viewportType !== Enums.ViewportType.STACK) { + return; + } + + const updateStackIndex = event => { + const { newImageIdIndex } = event.detail; + // find the index of imageId in the imageIds + setImageSliceData({ + imageIndex: newImageIdIndex, + numberOfSlices: viewportData.data.imageIds.length, + }); + }; + + element.addEventListener(Enums.Events.STACK_VIEWPORT_SCROLL, updateStackIndex); + + return () => { + element.removeEventListener(Enums.Events.STACK_VIEWPORT_SCROLL, updateStackIndex); + }; + }, [viewportData, element]); + + useEffect(() => { + if (viewportData?.viewportType !== Enums.ViewportType.ORTHOGRAPHIC) { + return; + } + + const updateVolumeIndex = event => { + const { imageIndex, numberOfSlices } = event.detail; + // find the index of imageId in the imageIds + setImageSliceData({ imageIndex, numberOfSlices }); + }; + + element.addEventListener(Enums.Events.VOLUME_NEW_IMAGE, updateVolumeIndex); + + return () => { + element.removeEventListener(Enums.Events.VOLUME_NEW_IMAGE, updateVolumeIndex); + }; + }, [viewportData, element]); + + useEffect(() => { + if (viewportData?.viewportType !== Enums.ViewportType.STACK) { + return; + } + + updateCachedSlices(); + + eventTarget.addEventListener(Enums.Events.IMAGE_CACHE_IMAGE_ADDED, updateCachedSlices); + eventTarget.addEventListener(Enums.Events.VOLUME_CACHE_VOLUME_ADDED, updateCachedSlices); + eventTarget.addEventListener(Enums.Events.IMAGE_CACHE_IMAGE_REMOVED, updateCachedSlices); + eventTarget.addEventListener(Enums.Events.VOLUME_CACHE_VOLUME_REMOVED, updateCachedSlices); + + return () => { + eventTarget.removeEventListener(Enums.Events.IMAGE_CACHE_IMAGE_ADDED, updateCachedSlices); + eventTarget.removeEventListener(Enums.Events.VOLUME_CACHE_VOLUME_ADDED, updateCachedSlices); + eventTarget.removeEventListener(Enums.Events.IMAGE_CACHE_IMAGE_REMOVED, updateCachedSlices); + eventTarget.removeEventListener(Enums.Events.VOLUME_CACHE_VOLUME_REMOVED, updateCachedSlices); + }; + }, [viewportData, numOfSlices]); + + useEffect(() => { + const onKeyDown = evt => { + // Checking the pressed key is Ctrl key + evt.keyCode === KEYS.Ctrl && setIsKeyPressed(true); + }; + + const onKeyUp = evt => { + // Checking the pressed key is Ctrl key + evt.keyCode === KEYS.Ctrl && setIsKeyPressed(false); + }; + + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + + return () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + }; + }, []); + + function updateCachedSlices() { + if (!viewportData?.data) { + return; + } + + const { cachedSlicesPerSeries } = stateSyncService.getState(); + const { imageIds, displaySetInstanceUID } = viewportData.data; + + const cachedImages = []; + imageIds.forEach((imageId, index) => { + if (cache.isLoaded(imageId)) { + cachedImages.push(index); + } + }); + + stateSyncService.store({ + cachedSlicesPerSeries: { ...cachedSlicesPerSeries, [displaySetInstanceUID]: cachedImages }, + }); + setCachedSlices(cachedImages); + } + + return ( + <> + {numOfSlices && ( + + {[...Array(numOfSlices)].map((_, index) => ( +
0 && 'border-t-[0.5px]', + index < numOfSlices - 1 && 'border-b-[0.5px]' + )} + style={{ height: `${(+scrollbarHeightValue + 2) / numOfSlices}px` }} + onClick={() => onImageScrollbarChange(index, viewportId)} + >
+ ))} +
+ )} + onImageScrollbarChange(imageIndex, viewportId)} + max={numOfSlices ? numOfSlices - 1 : 0} + height={scrollbarHeight} + value={imageSliceData.imageIndex} + /> + + ); +} + +SmartImageScrollbar.propTypes = { + viewportData: PropTypes.object, + viewportId: PropTypes.string.isRequired, + element: PropTypes.instanceOf(Element), + scrollbarHeight: PropTypes.string, + imageSliceData: PropTypes.object.isRequired, + setImageSliceData: PropTypes.func.isRequired, + servicesManager: PropTypes.object.isRequired, +}; + +export default SmartImageScrollbar; diff --git a/platform/ui/src/components/SmartScrollbar/index.js b/platform/ui/src/components/SmartScrollbar/index.js new file mode 100644 index 00000000000..22038173708 --- /dev/null +++ b/platform/ui/src/components/SmartScrollbar/index.js @@ -0,0 +1,2 @@ +import SmartScrollbar from './SmartScrollbar'; +export default SmartScrollbar; diff --git a/platform/ui/src/components/index.js b/platform/ui/src/components/index.js index b51bc92cf88..18188a2be13 100644 --- a/platform/ui/src/components/index.js +++ b/platform/ui/src/components/index.js @@ -79,6 +79,7 @@ import PanelSection from './PanelSection'; import AdvancedToolbox from './AdvancedToolbox'; import InputDoubleRange from './InputDoubleRange'; import LegacyButtonGroup from './LegacyButtonGroup'; +import SmartScrollbar from './SmartScrollbar'; export { AboutModal, @@ -132,6 +133,7 @@ export { SegmentationTable, SegmentationGroupTable, SidePanel, + SmartScrollbar, SplitButton, StudyBrowser, StudyItem, diff --git a/platform/ui/src/index.js b/platform/ui/src/index.js index 69fc814fa53..e5fb398f3b0 100644 --- a/platform/ui/src/index.js +++ b/platform/ui/src/index.js @@ -85,6 +85,7 @@ export { SegmentationTable, SegmentationGroupTable, SidePanel, + SmartScrollbar, SplitButton, StudyBrowser, StudyItem,