diff --git a/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx b/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx index 43ec800c455..34970d5ffd1 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: withAppTypes) { return (
- { + scroll: ({ direction, isSmartScrolling = false }) => { const enabledElement = _getActiveViewportEnabledElement(); if (!enabledElement) { @@ -566,6 +567,16 @@ function commandsModule({ const { viewport } = enabledElement; const options = { delta: direction }; + if ( + shouldPreventScroll( + !isSmartScrolling, + viewport.getCurrentImageIdIndex() + direction, + servicesManager + ) + ) { + return; + } + cstUtils.scroll(viewport, options); }, setViewportColormap: ({ diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index e74a0de9ffc..5a16c4573bb 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -40,6 +40,7 @@ import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/g import { findNearbyToolData } from './utils/findNearbyToolData'; import { createFrameViewSynchronizer } from './synchronizers/frameViewSynchronizer'; import { getSopClassHandlerModule } from './getSopClassHandlerModule'; +import shouldPreventScroll from './utils/shouldPreventScroll'; const { helpers: volumeLoaderHelpers } = csStreamingImageVolumeLoader; const { getDynamicVolumeInfo } = volumeLoaderHelpers ?? {}; @@ -209,6 +210,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 bfabc4a0142..9a34a90acfe 100644 --- a/extensions/cornerstone/src/initCornerstoneTools.js +++ b/extensions/cornerstone/src/initCornerstoneTools.js @@ -42,6 +42,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; @@ -87,6 +89,8 @@ export default function initCornerstoneTools(configuration = {}) { addTool(OrientationMarkerTool); addTool(WindowLevelRegionTool); addTool(PlanarFreehandContourSegmentationTool); + addTool(SmartStackScrollMouseWheelTool); + addTool(SmartStackScrollTool); // Modify annotation tools to use dashed lines on SR const annotationStyle = { @@ -142,6 +146,8 @@ const toolNames = { OrientationMarker: OrientationMarkerTool.toolName, WindowLevelRegion: WindowLevelRegionTool.toolName, PlanarFreehandContourSegmentation: PlanarFreehandContourSegmentationTool.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 298f28612d6..6ad38e6bc35 100644 --- a/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts +++ b/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts @@ -252,8 +252,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 2e5e831c8e0..613ac9b12d0 100644 --- a/extensions/default/src/init.ts +++ b/extensions/default/src/init.ts @@ -61,6 +61,9 @@ export default function init({ // 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 }); + // Adds extra custom attributes for use by hanging protocols registerHangingProtocolAttributes({ servicesManager }); diff --git a/modes/longitudinal/src/initToolGroups.js b/modes/longitudinal/src/initToolGroups.js index d50bf4fe9c8..4634dbfd323 100644 --- a/modes/longitudinal/src/initToolGroups.js +++ b/modes/longitudinal/src/initToolGroups.js @@ -21,7 +21,7 @@ function initDefaultToolGroup( '@ohif/extension-cornerstone.utilityModule.tools' ); - const { toolNames, Enums } = utilityModule.exports; + const { toolNames, Enums, shouldPreventScroll } = utilityModule.exports; const tools = { active: [ @@ -37,7 +37,11 @@ function initDefaultToolGroup( toolName: toolNames.Zoom, bindings: [{ mouseButton: Enums.MouseBindings.Secondary }], }, - { toolName: toolNames.StackScrollMouseWheel, bindings: [] }, + { + toolName: toolNames.SmartStackScrollMouseWheel, + bindings: [], + configuration: { shouldPreventScroll }, + }, ], passive: [ { toolName: toolNames.Length }, @@ -71,7 +75,13 @@ function initDefaultToolGroup( { toolName: toolNames.EllipticalROI }, { toolName: toolNames.CircleROI }, { toolName: toolNames.RectangleROI }, - { toolName: toolNames.StackScroll }, + { + toolName: toolNames.SmartStackScroll, + bindings: [ + { mouseButton: Enums.MouseBindings.Primary, modifierKey: Enums.KeyboardBindings.Ctrl }, + ], + configuration: { shouldPreventScroll }, + }, { toolName: toolNames.Angle }, { toolName: toolNames.CobbAngle }, { toolName: toolNames.Magnify }, diff --git a/modes/longitudinal/src/moreTools.ts b/modes/longitudinal/src/moreTools.ts index 3c4ca9310d8..4c146fbb2c4 100644 --- a/modes/longitudinal/src/moreTools.ts +++ b/modes/longitudinal/src/moreTools.ts @@ -96,7 +96,7 @@ const moreTools = [ evaluate: 'evaluate.cornerstoneTool.toggle', }), createButton({ - id: 'StackScroll', + id: 'SmartStackScroll', icon: 'tool-stack-scroll', label: 'Stack Scroll', tooltip: 'Stack Scroll', diff --git a/modes/segmentation/src/initToolGroups.ts b/modes/segmentation/src/initToolGroups.ts index babfe4a1136..e75891a5d77 100644 --- a/modes/segmentation/src/initToolGroups.ts +++ b/modes/segmentation/src/initToolGroups.ts @@ -11,13 +11,17 @@ const colorsByOrientation = { }; 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: [ { @@ -84,7 +88,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 }, { toolName: toolNames.WindowLevelRegion }, diff --git a/modes/segmentation/src/toolbarButtons.ts b/modes/segmentation/src/toolbarButtons.ts index 89b4092c668..52f0dc6c569 100644 --- a/modes/segmentation/src/toolbarButtons.ts +++ b/modes/segmentation/src/toolbarButtons.ts @@ -165,7 +165,7 @@ const toolbarButtons: Button[] = [ evaluate: 'evaluate.cornerstoneTool.toggle', }), createButton({ - id: 'StackScroll', + id: 'SmartStackScroll', icon: 'tool-stack-scroll', label: 'Stack Scroll', tooltip: 'Stack Scroll', diff --git a/platform/core/src/defaults/hotkeyBindings.js b/platform/core/src/defaults/hotkeyBindings.js index b4c6098936c..d8f44f8d429 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 695e11e9cb5..50f495a14f0 100644 --- a/platform/ui/src/components/ImageScrollbar/ImageScrollbar.css +++ b/platform/ui/src/components/ImageScrollbar/ImageScrollbar.css @@ -1,6 +1,7 @@ .scroll { height: calc(100% - 30px); padding: 5px; + padding-left: 0; position: absolute; right: 0; top: 30px; diff --git a/platform/ui/src/components/SmartScrollbar/SmartScrollbar.tsx b/platform/ui/src/components/SmartScrollbar/SmartScrollbar.tsx new file mode 100644 index 00000000000..0756e5bc7d8 --- /dev/null +++ b/platform/ui/src/components/SmartScrollbar/SmartScrollbar.tsx @@ -0,0 +1,200 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { Enums, cache, eventTarget } from '@cornerstonejs/core'; +import { utilities as csToolsUtils } from '@cornerstonejs/tools'; +import { ImageScrollbar } from '@ohif/ui'; +import classNames from 'classnames'; + +const KEYS = { Ctrl: 17 }; + +function SmartImageScrollbar({ + viewportData, + viewportId, + element, + imageSliceData, + setImageSliceData, + scrollbarHeight, + servicesManager, +}: withAppTypes) { + const [cachedSlices, setCachedSlices] = useState([]); + const [isKeyPressed, setIsKeyPressed] = useState(false); + + const { cineService, cornerstoneViewportService, stateSyncService } = servicesManager.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, { viewportId }); + 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; + } + + const imageIndex = viewport.getCurrentImageIdIndex(); + const numberOfSlices = viewport.getNumberOfSlices(); + + setImageSliceData({ + imageIndex: imageIndex, + numberOfSlices, + }); + }, [viewportId, viewportData]); + + useEffect(() => { + if (!viewportData) { + return; + } + const { viewportType } = viewportData; + const eventId = + (viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_VIEWPORT_SCROLL) || + (viewportType === Enums.ViewportType.ORTHOGRAPHIC && Enums.Events.VOLUME_NEW_IMAGE) || + Enums.Events.IMAGE_RENDERED; + + const updateIndex = event => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + const { imageIndex, newImageIdIndex = imageIndex } = event.detail; + const numberOfSlices = viewport.getNumberOfSlices(); + // find the index of imageId in the imageIds + setImageSliceData({ + imageIndex: newImageIdIndex, + numberOfSlices, + }); + }; + + element.addEventListener(eventId, updateIndex); + + return () => { + element.removeEventListener(eventId, updateIndex); + }; + }, [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[0]; + + 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 || 0} + /> + + ); +} + +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 52020efd55a..f5d7ea69a4a 100644 --- a/platform/ui/src/components/index.js +++ b/platform/ui/src/components/index.js @@ -96,6 +96,7 @@ import MeasurementItem from './MeasurementTable/MeasurementItem'; import LayoutPreset from './LayoutPreset'; import ActionButtons from './ActionButtons'; import StudyBrowserSort from './StudyBrowserSort'; +import SmartScrollbar from './SmartScrollbar'; export { ActionButtons, @@ -153,6 +154,7 @@ export { SegmentationGroupTable, SegmentationGroupTableExpanded, SidePanel, + SmartScrollbar, SplitButton, StudyBrowser, StudyItem, diff --git a/platform/ui/src/index.js b/platform/ui/src/index.js index b240a6e4b5a..575873f723f 100644 --- a/platform/ui/src/index.js +++ b/platform/ui/src/index.js @@ -85,6 +85,7 @@ export { SegmentationGroupTable, SegmentationGroupTableExpanded, SidePanel, + SmartScrollbar, SplitButton, ProgressDropdown, LegacySplitButton,