diff --git a/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx b/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx index 43ec800c4..acef3c380 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx @@ -1,13 +1,14 @@ import React, { useEffect, useState } from 'react'; import ViewportImageScrollbar from './ViewportImageScrollbar'; +import ViewportSliceProgressScrollbar from './ViewportSliceProgressScrollbar/ViewportSliceProgressScrollbar'; import CustomizableViewportOverlay from './CustomizableViewportOverlay'; import ViewportOrientationMarkers from './ViewportOrientationMarkers'; import ViewportImageSliceLoadingIndicator from './ViewportImageSliceLoadingIndicator'; function CornerstoneOverlays(props: withAppTypes) { const { viewportId, element, scrollbarHeight, servicesManager } = props; - const { cornerstoneViewportService } = servicesManager.services; + const { cornerstoneViewportService, customizationService } = servicesManager.services; const [imageSliceData, setImageSliceData] = useState({ imageIndex: 0, numberOfSlices: 0, @@ -43,17 +44,31 @@ function CornerstoneOverlays(props: withAppTypes) { } } + const viewportScrollbarVariant = customizationService.getCustomization('viewportScrollbar.variant'); + const useProgressScrollbar = viewportScrollbarVariant !== 'legacy'; + return (
- + {useProgressScrollbar ? ( + + ) : ( + + )} {label ? {label} : null} - {value} + {value}
); } diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/ViewportSliceProgressScrollbar.tsx b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/ViewportSliceProgressScrollbar.tsx new file mode 100644 index 000000000..2e5987fe0 --- /dev/null +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/ViewportSliceProgressScrollbar.tsx @@ -0,0 +1,200 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { VolumeViewport3D, utilities as csUtils } from '@cornerstonejs/core'; +import { + SmartScrollbar, + SmartScrollbarTrack, + SmartScrollbarFill, + SmartScrollbarIndicator, + SmartScrollbarEndpoints, +} from '@ohif/ui-next'; +import { getViewportImageIds } from './helpers'; +import { + useLoadedSliceBytes, + useProgressScrollbarMode, + useViewedSliceBytes, + useViewportSliceSync, +} from './hooks'; +import { ViewportSliceProgressScrollbarProps } from './types'; + +function ViewportSliceProgressScrollbar({ + viewportData, + viewportId, + element, + imageSliceData, + setImageSliceData, + servicesManager, +}: ViewportSliceProgressScrollbarProps) { + const { cineService, cornerstoneViewportService, customizationService, viewedDataService } = + servicesManager.services; + + const showLoadedEndpoints = + customizationService.getCustomization('viewportScrollbar.showLoadedEndpoints') !== false; + const showLoadedFill = + customizationService.getCustomization('viewportScrollbar.showLoadedFill') !== false; + const showViewedFill = + customizationService.getCustomization('viewportScrollbar.showViewedFill') !== false; + const showLoadingPattern = + customizationService.getCustomization('viewportScrollbar.showLoadingPattern') !== false; + const viewedDwellMsRaw = customizationService.getCustomization('viewportScrollbar.viewedDwellMs'); + const loadedBatchIntervalMsRaw = customizationService.getCustomization( + 'viewportScrollbar.loadedBatchIntervalMs' + ); + const viewedDwellMs = + typeof viewedDwellMsRaw === 'number' && viewedDwellMsRaw >= 0 ? viewedDwellMsRaw : 0; + const loadedBatchIntervalMs = + typeof loadedBatchIntervalMsRaw === 'number' && loadedBatchIntervalMsRaw >= 0 + ? loadedBatchIntervalMsRaw + : 200; + + const { numberOfSlices, imageIndex } = imageSliceData; + + const imageIds = useMemo(() => getViewportImageIds(viewportData), [viewportData]); + const imageIdToIndex = useMemo(() => { + const map = new Map(); + for (let i = 0; i < imageIds.length; i++) { + const imageId = imageIds[i]; + if (imageId) { + map.set(imageId, i); + } + } + return map; + }, [imageIds]); + + const isFullMode = useProgressScrollbarMode({ + viewportData, + viewportId, + element, + cornerstoneViewportService, + }); + + useViewportSliceSync({ + viewportData, + viewportId, + element, + cornerstoneViewportService, + setImageSliceData, + }); + + const { + bytes: loadedBytes, + version: loadedVersion, + isFull: isFullyLoaded, + } = useLoadedSliceBytes({ + isFullMode, + numberOfSlices, + viewportData, + imageIds, + imageIdToIndex, + loadedBatchIntervalMs, + }); + + const { bytes: viewedBytes, version: viewedVersion } = useViewedSliceBytes({ + isFullMode, + numberOfSlices, + imageIndex, + imageIds, + imageIdToIndex, + viewedDwellMs, + viewedDataService, + }); + + const onScrollbarValueChange = targetImageIndex => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + + if (!viewport || viewport instanceof VolumeViewport3D) { + return; + } + + const { isCineEnabled } = cineService.getState(); + + if (isCineEnabled) { + cineService.stopClip(element, { viewportId }); + cineService.setCine({ id: viewportId, frameRate: undefined, isPlaying: false }); + } + + csUtils.jumpToSlice(viewport.element, { + imageIndex: targetImageIndex, + debounceLoading: true, + }); + }; + + const isLoading = isFullMode && showLoadingPattern ? !isFullyLoaded : false; + + if (!numberOfSlices || numberOfSlices <= 1) { + return null; + } + + return ( +
+
+ + | undefined + } + > + + {isFullMode && showLoadedFill && ( + + )} + {isFullMode && showViewedFill && ( + + )} + + + {isFullMode && showLoadedEndpoints && ( + + )} + +
+
+ ); +} + +ViewportSliceProgressScrollbar.propTypes = { + viewportData: PropTypes.object, + viewportId: PropTypes.string.isRequired, + element: PropTypes.instanceOf(Element), + imageSliceData: PropTypes.object.isRequired, + setImageSliceData: PropTypes.func.isRequired, + servicesManager: PropTypes.object.isRequired, +}; + +export default ViewportSliceProgressScrollbar; diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/helpers.ts b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/helpers.ts new file mode 100644 index 000000000..2f21d8238 --- /dev/null +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/helpers.ts @@ -0,0 +1,40 @@ +import { Enums, VolumeViewport3D } from '@cornerstonejs/core'; +import { ViewportData } from './types'; + +export function getImageIndexFromEvent(event): number | undefined { + const { imageIndex, newImageIdIndex = imageIndex, imageIdIndex } = event.detail; + return newImageIdIndex ?? imageIdIndex; +} + +export function getViewportImageIds(viewportData: ViewportData): string[] { + if (!viewportData?.data?.length) { + return []; + } + + const firstData = viewportData.data[0]; + const volumeImageIds = (firstData as any).volume?.imageIds as string[] | undefined; + const datumImageIds = (firstData as any).imageIds as string[] | undefined; + + return volumeImageIds || datumImageIds || []; +} + +export function isProgressFullMode(viewportData: ViewportData, viewport): boolean { + if (!viewportData || !viewport || viewport instanceof VolumeViewport3D) { + return false; + } + + if (viewportData.viewportType === Enums.ViewportType.STACK) { + return true; + } + + if (viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC) { + return !!viewport.isInAcquisitionPlane?.(); + } + + return false; +} + +export function getImageIdFromCacheEvent(event): string | undefined { + const detail = event?.detail; + return detail?.imageId || detail?.image?.imageId || detail?.cachedImage?.imageId; +} diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/hooks.ts b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/hooks.ts new file mode 100644 index 000000000..b5dd76b56 --- /dev/null +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/hooks.ts @@ -0,0 +1,353 @@ +import { useEffect, useRef, useState } from 'react'; +import { + cache as cornerstoneCache, + Enums, + eventTarget, + utilities, + VolumeViewport3D, +} from '@cornerstonejs/core'; +import { useByteArray } from '@ohif/ui-next'; +import { getImageIdFromCacheEvent, getImageIndexFromEvent, isProgressFullMode } from './helpers'; +import { ImageSliceData, ViewportData } from './types'; + +export function useProgressScrollbarMode({ + viewportData, + viewportId, + element, + cornerstoneViewportService, +}: { + viewportData: ViewportData; + viewportId: string; + element: HTMLElement; + cornerstoneViewportService: AppTypes.CornerstoneViewportService; +}) { + const [isFullMode, setIsFullMode] = useState(false); + const lastViewPlaneNormalRef = useRef(null); + + /** + * Tracks whether this viewport should render full progress UI (stack or acquisition-plane + * orthographic volume) versus minimal UI. We compute once on setup and recompute on each + * CAMERA_MODIFIED event so stack->MPR transitions and acquisition-plane changes are reflected + * immediately. + */ + useEffect(() => { + if (!viewportData) { + return; + } + + const updateMode = () => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + const viewportImageData = viewport?.getImageData?.(); + const nextViewPlaneNormal = viewport?.getCamera?.()?.viewPlaneNormal as number[] | undefined; + // Do not update the lastViewPlaneNormalRef until we have a valid viewportImageData. + // Without viewportImageData, the viewport is not fully initialized and the isAcquisitionPlane + // check will not be accurate. + if (viewportImageData && nextViewPlaneNormal) { + lastViewPlaneNormalRef.current = [...nextViewPlaneNormal]; + } + const nextMode = isProgressFullMode(viewportData, viewport); + setIsFullMode(prevMode => (prevMode === nextMode ? prevMode : nextMode)); + }; + + updateMode(); + + const onCameraModified = () => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + const nextViewPlaneNormal = viewport?.getCamera?.()?.viewPlaneNormal as number[] | undefined; + const previousViewPlaneNormal = lastViewPlaneNormalRef.current; + + // Ignore camera updates that keep the same orientation (pan/zoom/scroll). + if (nextViewPlaneNormal && previousViewPlaneNormal) { + if (utilities.isEqual(nextViewPlaneNormal, previousViewPlaneNormal)) { + return; + } + } + + updateMode(); + }; + element.addEventListener(Enums.Events.CAMERA_MODIFIED, onCameraModified); + + return () => { + element.removeEventListener(Enums.Events.CAMERA_MODIFIED, onCameraModified); + }; + }, [viewportData, viewportId, cornerstoneViewportService, element]); + + return isFullMode; +} + +export function useViewportSliceSync({ + viewportData, + viewportId, + element, + cornerstoneViewportService, + setImageSliceData, +}: { + viewportData: ViewportData; + viewportId: string; + element: HTMLElement; + cornerstoneViewportService: AppTypes.CornerstoneViewportService; + setImageSliceData: (data: ImageSliceData) => void; +}) { + /** + * Keeps shared slice state in sync: first initialize from the live viewport snapshot, then + * subscribe to navigation/render events for incremental updates while users scroll. + */ + useEffect(() => { + if (!viewportData) { + return; + } + + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + if (viewport && !(viewport instanceof VolumeViewport3D)) { + try { + const currentImageIndex = viewport.getCurrentImageIdIndex(); + const currentNumberOfSlices = viewport.getNumberOfSlices(); + + setImageSliceData({ + imageIndex: currentImageIndex, + numberOfSlices: currentNumberOfSlices, + }); + } catch (error) { + console.warn(error); + } + } + + const { viewportType } = viewportData; + const eventId = + (viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_NEW_IMAGE) || + (viewportType === Enums.ViewportType.ORTHOGRAPHIC && Enums.Events.VOLUME_NEW_IMAGE) || + Enums.Events.IMAGE_RENDERED; + + const updateIndex = event => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + if (!viewport || viewport instanceof VolumeViewport3D) { + return; + } + + const nextImageIndex = getImageIndexFromEvent(event); + if (nextImageIndex == null) { + return; + } + const nextNumberOfSlices = viewport.getNumberOfSlices(); + + setImageSliceData({ + imageIndex: nextImageIndex, + numberOfSlices: nextNumberOfSlices, + }); + }; + + element.addEventListener(eventId, updateIndex); + + return () => { + element.removeEventListener(eventId, updateIndex); + }; + }, [viewportData, element, viewportId, cornerstoneViewportService, setImageSliceData]); +} + +export function useLoadedSliceBytes({ + isFullMode, + numberOfSlices, + viewportData, + imageIds, + imageIdToIndex, + loadedBatchIntervalMs, +}: { + isFullMode: boolean; + numberOfSlices: number; + viewportData: ViewportData; + imageIds: string[]; + imageIdToIndex: Map; + loadedBatchIntervalMs: number; +}) { + const loadedState = useByteArray(numberOfSlices || 0, loadedBatchIntervalMs); + const { + resetWith: resetLoaded, + setByte: setLoadedByte, + clearByte: clearLoadedByte, + } = loadedState; + + /** + * Keeps the loaded byte array in sync with Cornerstone cache: seed from cache whenever stack / + * mode / slice count changes, then subscribe so cache add/remove updates stay incremental. + * Seeding runs immediately before registering listeners in the same effect. + */ + useEffect(() => { + if (isFullMode && numberOfSlices) { + resetLoaded(bytes => { + for (let i = 0; i < bytes.length; i++) { + const imageId = imageIds[i]; + if (imageId && cornerstoneCache.isLoaded(imageId)) { + bytes[i] = 1; + } + } + }); + } + + if (!isFullMode || !viewportData) { + return; + } + + const markLoaded = event => { + const imageId = getImageIdFromCacheEvent(event); + if (!imageId) { + return; + } + const index = imageIdToIndex.get(imageId); + if (index !== undefined) { + setLoadedByte(index); + } + }; + + const markRemoved = event => { + const imageId = getImageIdFromCacheEvent(event); + if (!imageId) { + return; + } + const index = imageIdToIndex.get(imageId); + if (index !== undefined) { + clearLoadedByte(index); + } + }; + + eventTarget.addEventListener(Enums.Events.IMAGE_CACHE_IMAGE_ADDED, markLoaded); + eventTarget.addEventListener(Enums.Events.IMAGE_CACHE_IMAGE_REMOVED, markRemoved); + + return () => { + eventTarget.removeEventListener(Enums.Events.IMAGE_CACHE_IMAGE_ADDED, markLoaded); + eventTarget.removeEventListener(Enums.Events.IMAGE_CACHE_IMAGE_REMOVED, markRemoved); + }; + }, [ + imageIds, + isFullMode, + numberOfSlices, + viewportData, + imageIdToIndex, + resetLoaded, + setLoadedByte, + clearLoadedByte, + ]); + + return loadedState; +} + +export function useViewedSliceBytes({ + isFullMode, + numberOfSlices, + imageIndex, + imageIds, + imageIdToIndex, + viewedDwellMs, + viewedDataService, +}: { + isFullMode: boolean; + numberOfSlices: number; + imageIndex: number; + imageIds: string[]; + imageIdToIndex: Map; + viewedDwellMs: number; + viewedDataService: AppTypes.ViewedDataService; +}) { + const viewedState = useByteArray(numberOfSlices || 0); + const { resetWith: resetViewed, setByte: setViewedByte } = viewedState; + + /** + * Keeps the viewed byte array in sync with the global viewed-data store: seed from the store + * whenever stack / mode / slice count changes, then subscribe so `markDataViewed` updates stay + * incremental. Seeding runs immediately before registering the listener in the same effect. + */ + useEffect(() => { + if (isFullMode && numberOfSlices) { + resetViewed(bytes => { + for (let i = 0; i < bytes.length; i++) { + const imageId = imageIds[i]; + if (imageId && viewedDataService?.isDataViewed(imageId)) { + bytes[i] = 1; + } + } + }); + } + + if (!viewedDataService) { + return; + } + + const subscription = viewedDataService.subscribeViewedDataChanges( + ({ + viewedDataId, + viewedDataCleared, + }: { + viewedDataId?: string; + viewedDataCleared?: boolean; + }) => { + if (!isFullMode || !numberOfSlices) { + return; + } + + if (viewedDataCleared) { + resetViewed(bytes => { + bytes.fill(0); + }); + return; + } + + const index = imageIdToIndex.get(viewedDataId); + if (index !== undefined) { + setViewedByte(index); + } + } + ); + + return () => { + subscription.unsubscribe(); + }; + }, [ + imageIds, + isFullMode, + numberOfSlices, + imageIdToIndex, + resetViewed, + setViewedByte, + viewedDataService, + ]); + + /** + * Marks slices as viewed in full mode. With `viewedDwellMs === 0`, marking is immediate on + * index change; otherwise a dwell timer is used and cleaned up on subsequent changes/unmount. + */ + useEffect(() => { + if (!isFullMode || !numberOfSlices) { + return; + } + + const markViewed = (targetIndex: number) => { + setViewedByte(targetIndex); + const imageId = imageIds[targetIndex]; + if (imageId) { + viewedDataService?.markDataViewed(imageId); + } + }; + + if (viewedDwellMs === 0) { + markViewed(imageIndex || 0); + return; + } + + const timerId = window.setTimeout(() => { + markViewed(imageIndex || 0); + }, viewedDwellMs); + + return () => { + window.clearTimeout(timerId); + }; + }, [ + isFullMode, + numberOfSlices, + imageIndex, + imageIds, + setViewedByte, + viewedDwellMs, + viewedDataService, + ]); + + return viewedState; +} diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/index.ts b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/index.ts new file mode 100644 index 000000000..66153dbd4 --- /dev/null +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/index.ts @@ -0,0 +1 @@ +export { default } from './ViewportSliceProgressScrollbar'; diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/types.ts b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/types.ts new file mode 100644 index 000000000..3d3300fb9 --- /dev/null +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/types.ts @@ -0,0 +1,17 @@ +import { StackViewportData, VolumeViewportData } from '../../../types/CornerstoneCacheService'; + +export type ViewportData = StackViewportData | VolumeViewportData; + +export type ImageSliceData = { + imageIndex: number; + numberOfSlices: number; +}; + +export type ViewportSliceProgressScrollbarProps = { + viewportData: ViewportData | null; + viewportId: string; + element: HTMLElement; + imageSliceData: ImageSliceData; + setImageSliceData: (data: ImageSliceData) => void; + servicesManager: AppTypes.ServicesManager; +}; diff --git a/extensions/cornerstone/src/customizations/viewportScrollbarCustomization.tsx b/extensions/cornerstone/src/customizations/viewportScrollbarCustomization.tsx new file mode 100644 index 000000000..6deb29487 --- /dev/null +++ b/extensions/cornerstone/src/customizations/viewportScrollbarCustomization.tsx @@ -0,0 +1,27 @@ +/** + * Default customization values for viewport scrollbar behavior. + * The `progress` scrollbar variant is in full mode for stack viewports and + * acquisition-plane orthographic viewports. + * Otherwise, viewports using the `progress` scrollbar show only the indicator. + * + * - `viewportScrollbar.variant`: `'progress' | 'legacy'` (default: `'progress'`) + * - `viewportScrollbar.showLoadedEndpoints`: show loaded-range endpoint caps in full mode + * - `viewportScrollbar.showLoadedFill`: show loaded/cached fill in full mode + * - `viewportScrollbar.showViewedFill`: show viewed fill in full mode + * - `viewportScrollbar.showLoadingPattern`: show loading pattern in full mode while not fully loaded + * - `viewportScrollbar.viewedDwellMs`: delay before marking current image viewed in full mode (ms) + * - `viewportScrollbar.loadedBatchIntervalMs`: loaded-state version batch interval in full mode (ms) + * - `viewportScrollbar.indicator`: optional custom indicator config + */ +export default function getViewportScrollbarCustomization() { + return { + 'viewportScrollbar.variant': 'progress', + 'viewportScrollbar.showLoadedEndpoints': true, + 'viewportScrollbar.showLoadedFill': true, + 'viewportScrollbar.showViewedFill': true, + 'viewportScrollbar.showLoadingPattern': true, + 'viewportScrollbar.viewedDwellMs': 0, + 'viewportScrollbar.loadedBatchIntervalMs': 200, + 'viewportScrollbar.indicator': {}, + }; +} diff --git a/extensions/cornerstone/src/getCustomizationModule.tsx b/extensions/cornerstone/src/getCustomizationModule.tsx index 16f0dc825..076d4b89e 100644 --- a/extensions/cornerstone/src/getCustomizationModule.tsx +++ b/extensions/cornerstone/src/getCustomizationModule.tsx @@ -11,6 +11,7 @@ import windowLevelPresetsCustomization from './customizations/windowLevelPresets import miscCustomization from './customizations/miscCustomization'; import captureViewportModalCustomization from './customizations/captureViewportModalCustomization'; import viewportDownloadWarningCustomization from './customizations/viewportDownloadWarningCustomization'; +import getViewportScrollbarCustomization from './customizations/viewportScrollbarCustomization'; function getCustomizationModule({ commandsManager, servicesManager, extensionManager }) { return [ @@ -34,6 +35,7 @@ function getCustomizationModule({ commandsManager, servicesManager, extensionMan ...miscCustomization, ...captureViewportModalCustomization, ...viewportDownloadWarningCustomization, + ...getViewportScrollbarCustomization(), }, }, ]; diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index 9ca80a5da..f2e2305bf 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -21,6 +21,7 @@ import SegmentationService from './services/SegmentationService'; import CornerstoneCacheService from './services/CornerstoneCacheService'; import CornerstoneViewportService from './services/ViewportService/CornerstoneViewportService'; import ColorbarService from './services/ColorbarService'; +import ViewedDataService from './services/ViewedDataService'; import * as CornerstoneExtensionTypes from './types'; import { toolNames } from './initCornerstoneTools'; @@ -180,6 +181,7 @@ const cornerstoneExtension: Types.Extensions.Extension = { useSelectedSegmentationsForViewportStore .getState() .clearSelectedSegmentationsForViewportStore(); + servicesManager.services.viewedDataService?.clearViewedData(); segmentationService.removeAllSegmentations(); }, @@ -196,6 +198,7 @@ const cornerstoneExtension: Types.Extensions.Extension = { servicesManager.registerService(SegmentationService.REGISTRATION); servicesManager.registerService(CornerstoneCacheService.REGISTRATION); servicesManager.registerService(ColorbarService.REGISTRATION); + servicesManager.registerService(ViewedDataService.REGISTRATION); const { syncGroupService } = servicesManager.services; syncGroupService.registerCustomSynchronizer('frameview', createFrameViewSynchronizer); diff --git a/extensions/cornerstone/src/services/ViewedDataService/ViewedDataService.ts b/extensions/cornerstone/src/services/ViewedDataService/ViewedDataService.ts new file mode 100644 index 000000000..c4927f752 --- /dev/null +++ b/extensions/cornerstone/src/services/ViewedDataService/ViewedDataService.ts @@ -0,0 +1,58 @@ +import { PubSubService } from '@ohif/core'; + +export type ViewedDataPayload = { + viewedDataId?: string; + viewedDataCleared?: boolean; +}; + +class ViewedDataService extends PubSubService { + public static readonly EVENTS = { + VIEWED_DATA_CHANGED: 'event::viewedDataChanged', + }; + + public static REGISTRATION = { + name: 'viewedDataService', + altName: 'ViewedDataService', + create: () => { + return new ViewedDataService(); + }, + }; + + private viewedDataIds = new Set(); + + constructor() { + super(ViewedDataService.EVENTS); + } + + public markDataViewed(dataId: string): void { + if (!dataId || this.viewedDataIds.has(dataId)) { + return; + } + + this.viewedDataIds.add(dataId); + this._broadcastEvent(this.EVENTS.VIEWED_DATA_CHANGED, { + viewedDataId: dataId, + }); + } + + public isDataViewed(dataId: string): boolean { + if (!dataId) { + return false; + } + + return this.viewedDataIds.has(dataId); + } + + public clearViewedData(): void { + this.viewedDataIds.clear(); + this._broadcastEvent(this.EVENTS.VIEWED_DATA_CHANGED, { + viewedDataCleared: true, + }); + } + + public subscribeViewedDataChanges(listener: (payload: ViewedDataPayload) => void) { + return this.subscribe(this.EVENTS.VIEWED_DATA_CHANGED, listener); + } +} + +export default ViewedDataService; diff --git a/extensions/cornerstone/src/services/ViewedDataService/index.ts b/extensions/cornerstone/src/services/ViewedDataService/index.ts new file mode 100644 index 000000000..849b91d34 --- /dev/null +++ b/extensions/cornerstone/src/services/ViewedDataService/index.ts @@ -0,0 +1,3 @@ +import ViewedDataService from './ViewedDataService'; + +export default ViewedDataService; diff --git a/extensions/cornerstone/src/types/AppTypes.ts b/extensions/cornerstone/src/types/AppTypes.ts index 0942f9e23..69bcdc4d9 100644 --- a/extensions/cornerstone/src/types/AppTypes.ts +++ b/extensions/cornerstone/src/types/AppTypes.ts @@ -5,6 +5,7 @@ import SegmentationServiceType from '../services/SegmentationService'; import SyncGroupServiceType from '../services/SyncGroupService'; import ToolGroupServiceType from '../services/ToolGroupService'; import ColorbarServiceType from '../services/ColorbarService'; +import ViewedDataServiceType from '../services/ViewedDataService'; import * as cornerstone from '@cornerstonejs/core'; import * as cornerstoneTools from '@cornerstonejs/tools'; @@ -23,6 +24,7 @@ declare global { export type SyncGroupService = SyncGroupServiceType; export type ToolGroupService = ToolGroupServiceType; export type ColorbarService = ColorbarServiceType; + export type ViewedDataService = ViewedDataServiceType; export interface Services { cornerstoneViewportService?: CornerstoneViewportServiceType; @@ -31,6 +33,7 @@ declare global { segmentationService?: SegmentationServiceType; cornerstoneCacheService?: CornerstoneCacheServiceType; colorbarService?: ColorbarServiceType; + viewedDataService?: ViewedDataServiceType; } export namespace Segmentation { diff --git a/platform/app/cypress/support/commands.js b/platform/app/cypress/support/commands.js index 62b3933f0..032b984db 100644 --- a/platform/app/cypress/support/commands.js +++ b/platform/app/cypress/support/commands.js @@ -471,26 +471,56 @@ Cypress.Commands.add('openPreferences', () => { }); Cypress.Commands.add('scrollToIndex', index => { - // Workaround implemented based on Cypress issue: - // https://github.com/cypress-io/cypress/issues/1570#issuecomment-450966053 - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - 'value' - ).set; + cy.get('.cornerstone-viewport-element:visible') + .first() + .then($viewportEl => { + cy.window().then(win => { + const cornerstone = win.cornerstone; + const element = $viewportEl[0]; + const enabledElement = + cornerstone?.getEnabledElement?.(element) || + cornerstone?.getEnabledElements?.().find(item => item.element === element); - cy.get('input.imageSlider[type=range]').then($range => { - // get the DOM node - const range = $range[0]; - // set the value manually - nativeInputValueSetter.call(range, index); - // now dispatch the event - range.dispatchEvent( - new Event('change', { - value: index, - bubbles: true, - }) - ); - }); + if (!enabledElement?.viewport) { + throw new Error('scrollToIndex: no enabled cornerstone viewport found'); + } + + const viewport = enabledElement.viewport; + const numberOfSlices = viewport.getNumberOfSlices?.() ?? 0; + + if (!numberOfSlices) { + throw new Error('scrollToIndex: viewport has no slices'); + } + + const targetIndex = index < 0 ? numberOfSlices + index : index; + + if (targetIndex < 0 || targetIndex >= numberOfSlices) { + throw new Error( + `scrollToIndex: index ${index} resolves to ${targetIndex}, but range is 0-${ + numberOfSlices - 1 + }` + ); + } + + return Cypress.Promise.resolve( + cornerstone.utilities.jumpToSlice(element, { + imageIndex: targetIndex, + debounceLoading: false, + }) + ).then(() => { + const currentIndex = viewport.getCurrentImageIdIndex?.(); + + if (currentIndex !== targetIndex) { + throw new Error( + `scrollToIndex: expected slice ${targetIndex}, but viewport is at ${currentIndex}` + ); + } + }); + }); + }); + + // Give the viewport a brief settle time after programmatic jump. + cy.wait(50); }); Cypress.Commands.add('closePreferences', () => { diff --git a/platform/docs/docs/assets/img/viewport-scrollbar-showLoadedEndpoints.png b/platform/docs/docs/assets/img/viewport-scrollbar-showLoadedEndpoints.png new file mode 100644 index 000000000..ab5398a5b Binary files /dev/null and b/platform/docs/docs/assets/img/viewport-scrollbar-showLoadedEndpoints.png differ diff --git a/platform/docs/docs/assets/img/viewport-scrollbar-showLoadedFill.png b/platform/docs/docs/assets/img/viewport-scrollbar-showLoadedFill.png new file mode 100644 index 000000000..ce5360e5f Binary files /dev/null and b/platform/docs/docs/assets/img/viewport-scrollbar-showLoadedFill.png differ diff --git a/platform/docs/docs/assets/img/viewport-scrollbar-showLoadingPattern.png b/platform/docs/docs/assets/img/viewport-scrollbar-showLoadingPattern.png new file mode 100644 index 000000000..7ec627f9a Binary files /dev/null and b/platform/docs/docs/assets/img/viewport-scrollbar-showLoadingPattern.png differ diff --git a/platform/docs/docs/assets/img/viewport-scrollbar-showViewedFill.png b/platform/docs/docs/assets/img/viewport-scrollbar-showViewedFill.png new file mode 100644 index 000000000..d5b733504 Binary files /dev/null and b/platform/docs/docs/assets/img/viewport-scrollbar-showViewedFill.png differ diff --git a/platform/docs/docs/assets/img/viewport-scrollbar-variant-legacy.png b/platform/docs/docs/assets/img/viewport-scrollbar-variant-legacy.png new file mode 100644 index 000000000..3d70c4793 Binary files /dev/null and b/platform/docs/docs/assets/img/viewport-scrollbar-variant-legacy.png differ diff --git a/platform/docs/docs/assets/img/viewport-scrollbar-variant-progress.png b/platform/docs/docs/assets/img/viewport-scrollbar-variant-progress.png new file mode 100644 index 000000000..a071d44d6 Binary files /dev/null and b/platform/docs/docs/assets/img/viewport-scrollbar-variant-progress.png differ diff --git a/platform/docs/docs/platform/services/customization-service/ViewportScrollbar.md b/platform/docs/docs/platform/services/customization-service/ViewportScrollbar.md new file mode 100644 index 000000000..4d26df0a2 --- /dev/null +++ b/platform/docs/docs/platform/services/customization-service/ViewportScrollbar.md @@ -0,0 +1,26 @@ +--- +title: Viewport Scrollbar Customization +summary: Documentation for configuring OHIF viewport scrollbar behavior, including progress vs legacy mode, loaded/viewed tracking visuals, loading pattern behavior, timing controls, and viewportScrollbar.indicator (size + optional custom indicator). +sidebar_position: 7 +--- + +# Viewport Scrollbar + +The viewport scrollbar customization controls whether OHIF uses: + +- the new progress-based scrollbar (`viewportScrollbar.variant: 'progress'`), or +- the legacy range-input scrollbar (`viewportScrollbar.variant: 'legacy'`). + +When using `'progress'`, stack and acquisition-plane volume viewports run in full progress mode (fills/endpoints/loading options apply - see below), while other slice-capable viewports run in minimal mode (indicator only). + +## Advanced: `viewportScrollbar.indicator` {#viewport-scrollbar-indicator-advanced} + +`viewportScrollbar.indicator` sets the progress indicator’s **outer size** (`totalWidth`, `totalHeight`, border included) and a **`renderIndicator`** function. The platform passes **`React`** into `renderIndicator` so the same shape works from plain **`window.config`** files (use `React.createElement` there instead of JSX). If **`totalWidth`**, **`totalHeight`**, and **`renderIndicator`** are not all valid, the default pill indicator is used. + +For richer UI, declare the override in an extension **`getCustomizationModule`** (or another **`.tsx`** module) where you can return real JSX from `renderIndicator`. + +To read layout while drawing the indicator (track size, loading state, **`isDragging`**, etc.), use **`useSmartScrollbarLayoutContext`** from `@ohif/ui-next` inside a **small React component** that you return from `renderIndicator` (for example `return ` or `React.createElement(MyIndicator)`). **Do not** call that hook directly in the `renderIndicator` function body: that function is not a component render, so hooks would break the rules of React. + +import { viewportScrollbarCustomizations, TableGenerator } from './sampleCustomizations'; + +{TableGenerator(viewportScrollbarCustomizations)} diff --git a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx index c420bf46c..95ec61f6f 100644 --- a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx +++ b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx @@ -15,6 +15,12 @@ import progressLoading from '../../../assets/img/Loading-Indicator.png'; import loadingIndicatorProgress from '../../../assets/img/loading-indicator-icon.png'; import loadingIndicatorPercent from '../../../assets/img/loading-indicator-percent.png'; import viewportActionCorners from '../../../assets/img/viewport-action-corners.png'; +import viewportScrollbarVariantProgress from '../../../assets/img/viewport-scrollbar-variant-progress.png'; +import viewportScrollbarVariantLegacy from '../../../assets/img/viewport-scrollbar-variant-legacy.png'; +import viewportScrollbarShowLoadedEndpoints from '../../../assets/img/viewport-scrollbar-showLoadedEndpoints.png'; +import viewportScrollbarShowLoadedFill from '../../../assets/img/viewport-scrollbar-showLoadedFill.png'; +import viewportScrollbarShowViewedFill from '../../../assets/img/viewport-scrollbar-showViewedFill.png'; +import viewportScrollbarShowLoadingPattern from '../../../assets/img/viewport-scrollbar-showLoadingPattern.png'; import contextMenu from '../../../assets/img/context-menu.jpg'; import viewportDownloadWarning from '../../../assets/img/viewport-download-warning.png'; import segmentationOverlay from '../../../assets/img/segmentation-overlay.png'; @@ -159,6 +165,197 @@ window.config = { }, ]; +export const viewportScrollbarCustomizations = [ + { + id: 'viewportScrollbar.variant', + description: ( + <> + Controls which scrollbar implementation is rendered. Use progress for{' '} + ViewportSliceProgressScrollbar and legacy for ViewportImageScrollbar. + + ), + default: 'progress', + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'viewportScrollbar.variant': { + $set: 'legacy', + }, + }, + ], +}; + `, + image: [ + { img: viewportScrollbarVariantProgress, caption: 'progress (default)' }, + { img: viewportScrollbarVariantLegacy, caption: 'legacy' }, + ], + }, + { + id: 'viewportScrollbar.showLoadedEndpoints', + description: + 'Shows/hides loaded-range endpoint caps in full progress mode (stack or acquisition-plane volume).', + default: true, + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'viewportScrollbar.showLoadedEndpoints': { + $set: false, + }, + }, + ], +}; + `, + image: { img: viewportScrollbarShowLoadedEndpoints, caption: 'Loaded-range endpoint caps' }, + }, + { + id: 'viewportScrollbar.showLoadedFill', + description: 'Shows/hides the loaded/cached fill track in full progress mode.', + default: true, + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'viewportScrollbar.showLoadedFill': { + $set: false, + }, + }, + ], +}; + `, + image: { img: viewportScrollbarShowLoadedFill, caption: 'Loaded/cached fill track' }, + }, + { + id: 'viewportScrollbar.showViewedFill', + description: 'Shows/hides the viewed fill track in full progress mode.', + default: true, + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'viewportScrollbar.showViewedFill': { + $set: false, + }, + }, + ], +}; + `, + image: { img: viewportScrollbarShowViewedFill, caption: 'Viewed fill track' }, + }, + { + id: 'viewportScrollbar.showLoadingPattern', + description: + 'Shows/hides the dotted loading pattern for full progress mode. Minimal mode always disables this pattern.', + default: true, + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'viewportScrollbar.showLoadingPattern': { + $set: false, + }, + }, + ], +}; + `, + image: { img: viewportScrollbarShowLoadingPattern, caption: 'Dotted loading pattern' }, + }, + { + id: 'viewportScrollbar.viewedDwellMs', + description: + 'Minimum time in milliseconds the current slice must stay on screen before it is marked as viewed in full progress mode. 0 marks immediately.', + default: 0, + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'viewportScrollbar.viewedDwellMs': { + $set: 500, + }, + }, + ], +}; + `, + }, + { + id: 'viewportScrollbar.loadedBatchIntervalMs', + description: + 'Coalesces loaded/cached slice state changes into a single UI update at the configured interval in full progress mode. Lower values feel more responsive but trigger more re-renders; higher values reduce render churn but can make progress updates appear delayed. Set to 0 for immediate updates.', + default: 200, + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'viewportScrollbar.loadedBatchIntervalMs': { + $set: 100, + }, + }, + ], +}; + `, + }, + { + id: 'viewportScrollbar.indicator', + description: ( + <> + Outer size (totalWidth × totalHeight, border included) and{' '} + renderIndicator for the progress scrollbar indicator.{' '} + renderIndicator receives React for config file compatibility. All + three properties must be provided or the default pill is used. See{' '} + Advanced for TSX overrides and{' '} + useSmartScrollbarLayoutContext. + + ), + default: '{}', + configurationIntro: ( +

+ This example sets a 10×10 SVG indicator (muted outer circle, lighter inner + circle) via React.createElement, matching totalWidth and{' '} + totalHeight. +

+ ), + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'viewportScrollbar.indicator': { + totalWidth: 10, + totalHeight: 10, + renderIndicator: function (React) { + return React.createElement( + 'svg', + { width: 10, height: 10, viewBox: '0 0 10 10' }, + React.createElement('circle', { + cx: 5, + cy: 5, + r: 5, + fill: 'hsl(213 22% 59% / 0.9)', + }), + React.createElement('circle', { + cx: 5, + cy: 5, + r: 4, + fill: 'hsl(0 0% 98% / 0.9)', + }) + ); + }, + }, + }, + ], +}; + `, + }, +]; + export const customizations = [ { id: 'ohif.hotkeyBindings', @@ -1895,76 +2092,149 @@ window.config = { }, ]; +function normalizeCustomizationImageItem(item: unknown): { + img: unknown; + caption?: React.ReactNode; +} { + if ( + item !== null && + typeof item === 'object' && + 'img' in item && + typeof (item as { img: unknown }).img !== 'undefined' + ) { + const o = item as { img: unknown; caption?: React.ReactNode }; + return { img: o.img, caption: o.caption }; + } + return { img: item }; +} + export const TableGenerator = (customizations: any[]) => { - return customizations.map(({ id, description, default: defaultValue, configuration, image }) => ( -
-

( +
- {id} -

- - - - - - - - - - - - - - - - - - - -
ID - {id} -
Description -
{description}
- {image && ( -
- {Array.isArray(image) ? ( - image.map((img, index) => ( - {`${id}-${index - )) - ) : ( - {id} - )} -
- )} -
Default Value -
-                {typeof defaultValue === 'string'
-                  ? defaultValue
-                  : JSON.stringify(defaultValue, null, 2)}
-              
-
Example - {configuration && ( -
-
-                    {configuration}
-                  
-
- )} -
-
- )); +

+ {id} +

+ + + + + + + + + + + + + + + + + + + +
ID + {id} +
Description +
{description}
+ {image && ( +
+ {Array.isArray(image) + ? image.map((item, index) => { + const { img, caption } = normalizeCustomizationImageItem(item); + const altText = + typeof caption === 'string' && caption.length > 0 + ? `${id}: ${caption}` + : `${id} screenshot ${index + 1}`; + return ( +
+ {altText} + {caption != null && caption !== '' && ( +
+ {caption} +
+ )} +
+ ); + }) + : (() => { + const { img, caption } = normalizeCustomizationImageItem(image); + const altText = + typeof caption === 'string' && caption.length > 0 + ? `${id}: ${caption}` + : id; + return ( +
+ {altText} + {caption != null && caption !== '' && ( +
+ {caption} +
+ )} +
+ ); + })()} +
+ )} +
+ Default Value + +
+                  {typeof defaultValue === 'string'
+                    ? defaultValue
+                    : JSON.stringify(defaultValue, null, 2)}
+                
+
Example + {configuration && ( +
+ {configurationIntro && ( +
+ {configurationIntro} +
+ )} +
+                      {configuration}
+                    
+
+ )} +
+ + ) + ); }; diff --git a/platform/docs/docs/platform/services/data/ViewedDataService.md b/platform/docs/docs/platform/services/data/ViewedDataService.md new file mode 100644 index 000000000..5e6e4c49d --- /dev/null +++ b/platform/docs/docs/platform/services/data/ViewedDataService.md @@ -0,0 +1,86 @@ +--- +sidebar_position: 9 +sidebar_label: Viewed Data Service +title: Viewed Data Service +summary: Documentation for OHIF's ViewedDataService, which tracks dataIds a user has viewed and publishes change events for incremental UI updates. +--- + +# Viewed Data Service + +## Overview + +`ViewedDataService` tracks which dataIds have been marked as viewed in the +current session. Internally it stores ids in a `Set` and publishes a +single event whenever viewed state changes. + +Typical usage is to: + +- mark a data item as viewed when users land on a slice +- query whether a data item has been viewed to seed UI state +- subscribe to viewed changes for incremental updates +- clear viewed state when a context reset is needed + +## Events + +The following events are published by `ViewedDataService`. + +| Event | Description | +| --- | --- | +| `VIEWED_DATA_CHANGED` | Fired when one data item is newly marked viewed, or when all viewed data is cleared. | + +### Event payload + +```ts +type ViewedDataPayload = { + viewedDataId?: string; + viewedDataCleared?: boolean; +}; +``` + +- When a single data item is marked viewed: `{ viewedDataId: string }` +- When all viewed data is cleared: `{ viewedDataCleared: true }` + +## API + +- `markDataViewed(dataId: string): void` + + Marks one data item as viewed and emits `VIEWED_DATA_CHANGED` only if: + + - `dataId` is truthy, and + - it was not already marked viewed. + +- `isDataViewed(dataId: string): boolean` + + + Returns `true` if `dataId` is currently in the viewed set. + +- `clearViewedData(): void` + + Clears all viewed dataIds and emits `VIEWED_DATA_CHANGED` with +`{ viewedDataCleared: true }`. + +- `subscribeViewedDataChanges(listener): Subscription` + + Subscribes to `VIEWED_DATA_CHANGED` payloads. + + ```ts + const subscription = viewedDataService.subscribeViewedDataChanges(payload => { + if (payload.viewedDataCleared) { + // reset local viewed state + return; + } + + if (payload.viewedDataId) { + // mark one data item as viewed locally + } + }); + + // later + subscription.unsubscribe(); + ``` + +## Notes + +- Service registration name: `viewedDataService` +- Alternate registration name: `ViewedDataService` +- The service is session-scoped in-memory state; it is not persisted by default. diff --git a/platform/docs/docs/platform/services/data/index.md b/platform/docs/docs/platform/services/data/index.md index 6a30a6fbc..6c2c2e03a 100644 --- a/platform/docs/docs/platform/services/data/index.md +++ b/platform/docs/docs/platform/services/data/index.md @@ -23,6 +23,7 @@ We maintain the following non-ui Services: - [Measurement Service](../data/MeasurementService.md) - [Customization Service](./../customization-service/customizationService.md) - [Panel Service](../data/PanelService.md) +- [Viewed Data Service](../data/ViewedDataService.md) ## Service Architecture diff --git a/platform/docs/docs/platform/services/index.md b/platform/docs/docs/platform/services/index.md index 814bd446b..5acb0ee96 100644 --- a/platform/docs/docs/platform/services/index.md +++ b/platform/docs/docs/platform/services/index.md @@ -105,6 +105,17 @@ The following services is available in the `OHIF-v3`. ToolBarService + + + + ViewedDataService + + + Data Service + + viewedDataService + + diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx index 1ace166a4..a7181d31a 100644 --- a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx @@ -9,8 +9,8 @@ import React, { Children, isValidElement, } from 'react'; -import { getIndicatorLayout } from './utils'; import { SmartScrollbarIndicator } from './SmartScrollbarIndicator'; +import { DEFAULT_INDICATOR_CONFIG } from './defaultSmartScrollbarIndicatorConfig'; // ── Child validation ──────────────────────────────────────────── function validateChildren(children: React.ReactNode): void { @@ -33,19 +33,56 @@ function validateChildren(children: React.ReactNode): void { const TRACK_WIDTH = 8; const RESTING_WIDTH = 4; const FILL_PADDING = 3; -const INDICATOR_SIZE = 8; -const INDICATOR_BORDER_WIDTH = 1; const SETTLE_DELAY = 600; +/** Package-internal type; not re-exported from `@ohif/ui-next` barrels. */ +export interface SmartScrollbarIndicatorConfig { + totalWidth?: number; + totalHeight?: number; + renderIndicator?: (react: typeof React) => React.ReactNode; +} + +/** + * Maps a raw record payload into partial `SmartScrollbarIndicatorConfig`. + * + * Supported keys on `raw`: + * - `totalWidth`, `totalHeight` — positive numbers + * - `renderIndicator` — `(React) => ReactNode` + */ +function normalizeIndicatorRecord( + raw: Record | null | undefined +): SmartScrollbarIndicatorConfig { + if (raw == null) { + return {}; + } + const config: SmartScrollbarIndicatorConfig = {}; + if (typeof raw.totalWidth === 'number' && raw.totalWidth > 0) { + config.totalWidth = raw.totalWidth; + } + if (typeof raw.totalHeight === 'number' && raw.totalHeight > 0) { + config.totalHeight = raw.totalHeight; + } + + if (typeof raw.renderIndicator === 'function') { + config.renderIndicator = raw.renderIndicator as (react: typeof React) => React.ReactNode; + } + + return config; +} + // ── Contexts ─────────────────────────────────────────────────── export interface SmartScrollbarLayoutContextValue { total: number; trackHeight: number; isLoading: boolean; + isDragging: boolean; effectiveWidth: number; trackWidth: number; fillPadding: number; stableLayerEl: HTMLDivElement | null; + indicatorTotalWidth: number; + indicatorTotalHeight: number; + renderIndicator: (react: typeof React) => React.ReactNode; } const SmartScrollbarLayoutContext = createContext(null); @@ -74,6 +111,10 @@ interface SmartScrollbarProps { enableKeyboardNavigation?: boolean; 'aria-label'?: string; className?: string; + /** + * Indicator payload parsed inside the component. + */ + indicator?: Record; children: React.ReactNode; } @@ -86,10 +127,24 @@ export function SmartScrollbar({ enableKeyboardNavigation = false, 'aria-label': ariaLabel = 'Scroll position', className, + indicator, children, }: SmartScrollbarProps) { validateChildren(children); + const resolvedIndicator = useMemo(() => { + const defaultIndicatorConfig = DEFAULT_INDICATOR_CONFIG; + const parsed = normalizeIndicatorRecord(indicator); + if (parsed.totalWidth && parsed.totalHeight && parsed.renderIndicator) { + return { + totalWidth: parsed.totalWidth, + totalHeight: parsed.totalHeight, + renderIndicator: parsed.renderIndicator, + }; + } + return defaultIndicatorConfig; + }, [indicator]); + // ── ResizeObserver for trackHeight ─────────────────────────── const containerRef = useRef(null); const [trackHeight, setTrackHeight] = useState(0); @@ -97,6 +152,12 @@ export function SmartScrollbar({ useEffect(() => { const el = containerRef.current; if (!el) return; + const syncTrackHeight = () => { + const measuredHeight = el.getBoundingClientRect().height; + setTrackHeight(prev => (prev === measuredHeight ? prev : measuredHeight)); + }; + // Capture an immediate measurement so we don't rely solely on async ResizeObserver delivery. + syncTrackHeight(); const ro = new ResizeObserver(([entry]) => { setTrackHeight(entry.contentRect.height); }); @@ -127,9 +188,8 @@ export function SmartScrollbar({ const isExpanded = !hasSettled || isHovered || isDragging; const effectiveWidth = isExpanded ? TRACK_WIDTH : RESTING_WIDTH; - // ── Hit zone extension ─────────────────────────────────────── - const { leftPos } = getIndicatorLayout(TRACK_WIDTH, INDICATOR_SIZE, INDICATOR_BORDER_WIDTH); - const hitZoneLeftExtension = Math.max(0, -leftPos); + // ── Hit zone: extend past `TRACK_WIDTH` on both sides when the pill is wider than the track ── + const hitZoneSideExtension = Math.max(0, resolvedIndicator.totalWidth / 2 - TRACK_WIDTH / 2); // ── Stable layer (for elements that shouldn't move during contraction) ── // Uses useState + callback ref so React triggers a re-render when the @@ -137,14 +197,21 @@ export function SmartScrollbar({ const [stableLayerEl, setStableLayerEl] = useState(null); // ── Pointer helpers ────────────────────────────────────────── - const clamp = useCallback( - (val: number) => Math.max(0, Math.min(total - 1, val)), - [total] - ); + const clamp = useCallback((val: number) => Math.max(0, Math.min(total - 1, val)), [total]); const indexFromPointerY = useCallback( (clientY: number) => { - const ratio = Math.max(0, Math.min(1, (clientY - trackTopRef.current) / trackHeight)); + if (trackHeight <= 0) { + return 0; + } + + // Map pointer Y within the same padded fill strip used by fill/indicator. + const fillTop = trackTopRef.current + FILL_PADDING; + const fillHeight = trackHeight - FILL_PADDING * 2; + if (fillHeight <= 0) { + return 0; + } + const ratio = Math.max(0, Math.min(1, (clientY - fillTop) / fillHeight)); return Math.round(ratio * (total - 1)); }, [trackHeight, total] @@ -153,6 +220,9 @@ export function SmartScrollbar({ const handlePointerDown = useCallback( (e: React.PointerEvent) => { trackTopRef.current = e.currentTarget.getBoundingClientRect().top; + if (trackHeight <= 0) { + return; + } isDraggingRef.current = true; setIsDragging(true); @@ -160,7 +230,7 @@ export function SmartScrollbar({ onValueChange(clamp(indexFromPointerY(e.clientY))); }, - [clamp, indexFromPointerY, onValueChange] + [clamp, indexFromPointerY, onValueChange, trackHeight] ); const handlePointerMove = useCallback( @@ -216,15 +286,32 @@ export function SmartScrollbar({ ); // ── Context values ─────────────────────────────────────────── - const layoutCtx = useMemo(() => ({ - total, - trackHeight, - isLoading, - effectiveWidth, - trackWidth: TRACK_WIDTH, - fillPadding: FILL_PADDING, - stableLayerEl, - }), [total, trackHeight, isLoading, effectiveWidth, stableLayerEl]); + const layoutCtx = useMemo( + () => ({ + total, + trackHeight, + isLoading, + isDragging, + effectiveWidth, + trackWidth: TRACK_WIDTH, + fillPadding: FILL_PADDING, + stableLayerEl, + indicatorTotalWidth: resolvedIndicator.totalWidth, + indicatorTotalHeight: resolvedIndicator.totalHeight, + renderIndicator: resolvedIndicator.renderIndicator, + }), + [ + total, + trackHeight, + isLoading, + isDragging, + effectiveWidth, + stableLayerEl, + resolvedIndicator.totalWidth, + resolvedIndicator.totalHeight, + resolvedIndicator.renderIndicator, + ] + ); return ( @@ -239,10 +326,10 @@ export function SmartScrollbar({ tabIndex={0} className={className} style={{ - width: TRACK_WIDTH + hitZoneLeftExtension, + width: TRACK_WIDTH + hitZoneSideExtension * 2, height: '100%', position: 'relative', - marginLeft: -hitZoneLeftExtension, + marginLeft: -hitZoneSideExtension, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none', }} @@ -258,7 +345,7 @@ export function SmartScrollbar({
- - {/* Border rect */} - - {/* Fill rect */} - - + {renderIndicator(React)}
); } diff --git a/platform/ui-next/src/components/SmartScrollbar/defaultSmartScrollbarIndicatorConfig.tsx b/platform/ui-next/src/components/SmartScrollbar/defaultSmartScrollbarIndicatorConfig.tsx new file mode 100644 index 000000000..6a1652bf2 --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/defaultSmartScrollbarIndicatorConfig.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { type SmartScrollbarIndicatorConfig } from './SmartScrollbar'; + +/** Ring inset for the default pill SVG (outer size is indicator total width × height). */ +const BORDER_WIDTH = 1; + +const INDICATOR_COLOR = 'hsl(var(--foreground) / 0.9)'; +const BORDER_COLOR = 'hsl(var(--neutral) / 0.9)'; +const TOTAL_WIDTH = 12; +const TOTAL_HEIGHT = 7; + +function DefaultIndicator() { + const borderWidth = BORDER_WIDTH; + const fillWidth = Math.max(0, TOTAL_WIDTH - borderWidth * 2); + const fillHeight = Math.max(0, TOTAL_HEIGHT - borderWidth * 2); + return ( + + + + + ); +} + +export const DEFAULT_INDICATOR_CONFIG: SmartScrollbarIndicatorConfig = { + totalWidth: TOTAL_WIDTH, + totalHeight: TOTAL_HEIGHT, + renderIndicator: (_react: typeof React) => , +}; diff --git a/platform/ui-next/src/components/SmartScrollbar/index.ts b/platform/ui-next/src/components/SmartScrollbar/index.ts index 1137a80c5..68c7a9716 100644 --- a/platform/ui-next/src/components/SmartScrollbar/index.ts +++ b/platform/ui-next/src/components/SmartScrollbar/index.ts @@ -1,5 +1,8 @@ -export { SmartScrollbar, useSmartScrollbarLayoutContext, useSmartScrollbarScrollContext } from './SmartScrollbar'; -export type { SmartScrollbarLayoutContextValue } from './SmartScrollbar'; +export { + SmartScrollbar, + useSmartScrollbarLayoutContext, + useSmartScrollbarScrollContext, +} from './SmartScrollbar'; export { SmartScrollbarTrack } from './SmartScrollbarTrack'; export { SmartScrollbarFill } from './SmartScrollbarFill'; export { SmartScrollbarIndicator } from './SmartScrollbarIndicator'; diff --git a/platform/ui-next/src/components/SmartScrollbar/useByteArray.ts b/platform/ui-next/src/components/SmartScrollbar/useByteArray.ts index 84024fce0..fe9319784 100644 --- a/platform/ui-next/src/components/SmartScrollbar/useByteArray.ts +++ b/platform/ui-next/src/components/SmartScrollbar/useByteArray.ts @@ -1,5 +1,4 @@ -import debounce from 'lodash.debounce'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; export interface ByteArrayHandle { bytes: Uint8Array; @@ -16,45 +15,68 @@ export interface ByteArrayHandle { * detection via an incrementing version counter. * * @param size - Number of positions (e.g. total slices in a viewport). - * @param debounceMs - When > 0, version bumps are debounced by this many - * milliseconds. Byte writes are always immediate. Use for - * high-frequency sources (cache prefetch) to batch renders. - * Omit or pass 0 for immediate re-renders (e.g. viewed tracking). + * @param batchIntervalMs - When > 0, writes are coalesced into a scheduled + * flush: the first write starts a timer, the next + * flush bumps `version`, and the timer stops. New + * writes start a new interval window. Omit or pass 0 + * for immediate re-renders on every write. */ -export function useByteArray(size: number, debounceMs = 0): ByteArrayHandle { +export function useByteArray(size: number, batchIntervalMs = 0): ByteArrayHandle { const bytesRef = useRef(new Uint8Array(size)); const countRef = useRef(0); const [version, setVersion] = useState(0); + const timeoutIdRef = useRef(null); - // Debounced bump — recreated when debounceMs changes; cancelled on unmount - // or when debounceMs changes, following the lodash.debounce pattern used - // throughout ui-next (InputFilter, CinePlayer). - const debouncedBump = useMemo( - () => (debounceMs > 0 ? debounce(() => setVersion(v => v + 1), debounceMs) : null), - [debounceMs] - ); + const clearScheduledFlush = useCallback(() => { + if (timeoutIdRef.current !== null) { + window.clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = null; + } + }, []); - useEffect(() => { - return () => debouncedBump?.cancel(); - }, [debouncedBump]); + const flushScheduledVersion = useCallback(() => { + // End this timeout window after the scheduled flush. + clearScheduledFlush(); + setVersion(v => v + 1); + }, [clearScheduledFlush]); // Reset array only when size actually changes — skip on initial mount since // bytesRef is already initialised to the correct size via useRef. useEffect(() => { if (bytesRef.current.length === size) return; - debouncedBump?.cancel(); + // Drop any in-flight timeout window when resetting the underlying array. + clearScheduledFlush(); bytesRef.current = new Uint8Array(size); countRef.current = 0; setVersion(v => v + 1); - }, [size, debouncedBump]); + }, [size, clearScheduledFlush]); + + useEffect(() => { + // If timing changes mid-window, restart that timeout using the new timing. + const pendingTimeoutId = timeoutIdRef.current; + clearScheduledFlush(); + if (batchIntervalMs <= 0) { + if (pendingTimeoutId !== null) { + setVersion(v => v + 1); + } + return; + } + if (pendingTimeoutId !== null) { + timeoutIdRef.current = window.setTimeout(flushScheduledVersion, batchIntervalMs); + } + return () => clearScheduledFlush(); + }, [batchIntervalMs, clearScheduledFlush, flushScheduledVersion]); const bump = useCallback(() => { - if (debouncedBump) { - debouncedBump(); - } else { + if (batchIntervalMs <= 0) { setVersion(v => v + 1); + return; } - }, [debouncedBump]); + + if (timeoutIdRef.current === null) { + timeoutIdRef.current = window.setTimeout(flushScheduledVersion, batchIntervalMs); + } + }, [batchIntervalMs, flushScheduledVersion]); const setByte = useCallback( (index: number) => { diff --git a/platform/ui-next/src/components/SmartScrollbar/utils.test.ts b/platform/ui-next/src/components/SmartScrollbar/utils.test.ts index c03434c5a..7ecfac598 100644 --- a/platform/ui-next/src/components/SmartScrollbar/utils.test.ts +++ b/platform/ui-next/src/components/SmartScrollbar/utils.test.ts @@ -1,4 +1,8 @@ -import { computeContiguousRuns, computePixelFilledFromMarked } from './utils'; +import { + computeContiguousRuns, + computeIndicatorTopOffsetInFill, + computePixelFilledFromMarked, +} from './utils'; function u8(values: number[]): Uint8Array { return new Uint8Array(values); @@ -57,6 +61,43 @@ describe('computePixelFilledFromMarked', () => { }); }); +describe('computeIndicatorTopOffsetInFill', () => { + it('top-aligns first bucket, centers middle, bottom-aligns last (pixelCount > total)', () => { + const total = 3; + const pixelCount = 9; + const indicatorHeight = 2; + // Buckets: [0,3), [3,6), [6,9) + expect(computeIndicatorTopOffsetInFill(0, total, pixelCount, indicatorHeight)).toBe(0); + expect(computeIndicatorTopOffsetInFill(1, total, pixelCount, indicatorHeight)).toBe(3); + expect(computeIndicatorTopOffsetInFill(2, total, pixelCount, indicatorHeight)).toBe(7); + }); + + it('top-aligns to fill pixel row when total >= pixelCount (slices > pixels)', () => { + const total = 10; + const pixelCount = 3; + const indicatorHeight = 1; + // Same buckets as computePixelFilledFromMarked: row0 [0,3), row1 [3,6), row2 [6,10) + expect(computeIndicatorTopOffsetInFill(0, total, pixelCount, indicatorHeight)).toBe(0); + expect(computeIndicatorTopOffsetInFill(2, total, pixelCount, indicatorHeight)).toBe(0); + expect(computeIndicatorTopOffsetInFill(3, total, pixelCount, indicatorHeight)).toBe(1); + expect(computeIndicatorTopOffsetInFill(5, total, pixelCount, indicatorHeight)).toBe(1); + expect(computeIndicatorTopOffsetInFill(6, total, pixelCount, indicatorHeight)).toBe(2); + expect(computeIndicatorTopOffsetInFill(9, total, pixelCount, indicatorHeight)).toBe(2); + }); + + it('clamps when indicator is taller than remaining fill space', () => { + const total = 2; + const pixelCount = 5; + const indicatorHeight = 10; + expect(computeIndicatorTopOffsetInFill(0, total, pixelCount, indicatorHeight)).toBe(0); + expect(computeIndicatorTopOffsetInFill(1, total, pixelCount, indicatorHeight)).toBe(0); + }); + + it('returns 0 when pixelCount is 0', () => { + expect(computeIndicatorTopOffsetInFill(0, 5, 0, 4)).toBe(0); + }); +}); + describe('computeContiguousRuns', () => { it('returns [] for empty input', () => { expect(computeContiguousRuns(new Uint8Array(0))).toEqual([]); diff --git a/platform/ui-next/src/components/SmartScrollbar/utils.ts b/platform/ui-next/src/components/SmartScrollbar/utils.ts index e6467094f..c61b7d97d 100644 --- a/platform/ui-next/src/components/SmartScrollbar/utils.ts +++ b/platform/ui-next/src/components/SmartScrollbar/utils.ts @@ -26,6 +26,52 @@ export function computeContiguousRuns(bytes: Uint8Array): ContiguousRun[] { return runs; } +/** + * When `total >= pixelRowCount`: half-open item index range `[itemStart, itemEnd)` + * covered by fill pixel row `pixelIndex`. `pixelRowCount` is `floor(pixelCount)` from callers. + * Shared by `computePixelFilledFromMarked` and `computeIndicatorTopOffsetInFill` — keep in sync. + */ +function itemRangeForPixelRow( + pixelIndex: number, + total: number, + pixelRowCount: number +): { itemStart: number; itemEnd: number } { + const itemStart = Math.floor((pixelIndex * total) / pixelRowCount); + const itemEnd = Math.floor(((pixelIndex + 1) * total) / pixelRowCount); + return { itemStart, itemEnd }; +} + +/** + * When `total >= pixelRowCount`: which fill pixel row owns `itemIndex` for the same + * partition as `itemRangeForPixelRow` (inverse of scanning rows until + * `itemIndex ∈ [itemStart, itemEnd)`). + * + * Readable form: `ceil((itemIndex + 1) * pixelRowCount / total) - 1`, clamped to `[0, pixelRowCount - 1]`. + */ +function pixelRowIndexForItemDownsample( + itemIndex: number, + total: number, + pixelRowCount: number +): number { + const rowIndex = Math.ceil(((itemIndex + 1) * pixelRowCount) / total) - 1; + return Math.max(0, Math.min(pixelRowCount - 1, rowIndex)); +} + +/** + * When `pixelRowCount > total`: half-open pixel row range `[pixelStart, pixelEnd)` + * covered by `itemIndex`. `pixelRowCount` is `floor(pixelCount)` from callers. + * Shared by `computePixelFilledFromMarked` and `computeIndicatorTopOffsetInFill` — keep in sync. + */ +function pixelSpanForItem( + itemIndex: number, + total: number, + pixelRowCount: number +): { pixelStart: number; pixelEnd: number } { + const pixelStart = Math.floor((itemIndex * pixelRowCount) / total); + const pixelEnd = Math.floor(((itemIndex + 1) * pixelRowCount) / total); + return { pixelStart, pixelEnd }; +} + /** * Convert marked items (0/1 bytes) into a per-pixel fill mask (0/1 bytes). * The result is conservative in the sense that a pixel row is filled only when @@ -41,20 +87,19 @@ export function computePixelFilledFromMarked( pixelCount: number ): Uint8Array { const total = marked.length; - const count = Math.max(0, Math.floor(pixelCount)); - if (count === 0 || total <= 0) return new Uint8Array(0); + const pixelRowCount = Math.max(0, Math.floor(pixelCount)); + if (pixelRowCount === 0 || total <= 0) return new Uint8Array(0); - const pixelFilled = new Uint8Array(count); + const pixelFilled = new Uint8Array(pixelRowCount); - if (total >= count) { - for (let pixelIndex = 0; pixelIndex < count; pixelIndex++) { - const start = Math.floor((pixelIndex * total) / count); - const end = Math.floor(((pixelIndex + 1) * total) / count); - if (end <= start) continue; + if (total >= pixelRowCount) { + for (let pixelIndex = 0; pixelIndex < pixelRowCount; pixelIndex++) { + const { itemStart, itemEnd } = itemRangeForPixelRow(pixelIndex, total, pixelRowCount); + if (itemEnd <= itemStart) continue; let filled = 1; - for (let itemIndex = start; itemIndex < end; itemIndex++) { - if (marked[itemIndex] === 0) { + for (let i = itemStart; i < itemEnd; i++) { + if (marked[i] === 0) { filled = 0; break; } @@ -64,9 +109,8 @@ export function computePixelFilledFromMarked( } else { for (let itemIndex = 0; itemIndex < total; itemIndex++) { if (marked[itemIndex] === 0) continue; - const topPx = Math.floor((itemIndex * count) / total); - const bottomPx = Math.floor(((itemIndex + 1) * count) / total); - for (let pixel = topPx; pixel < bottomPx; pixel++) { + const { pixelStart, pixelEnd } = pixelSpanForItem(itemIndex, total, pixelRowCount); + for (let pixel = pixelStart; pixel < pixelEnd; pixel++) { pixelFilled[pixel] = 1; } } @@ -76,22 +120,49 @@ export function computePixelFilledFromMarked( } /** - * Compute the indicator's total visual dimensions and horizontal position. - * Design 27: pill shape, center position, 1px border. + * Vertical offset (px) of the indicator within the fill strip `[0, pixelCount)`, + * clamped so the indicator stays inside the track. + * + * - When `total >= pixelCount` (many slices, few pixel rows): same partition as + * `computePixelFilledFromMarked` (per-pixel item buckets); **top-align** the + * indicator to that row (then clamp so the thumb does not overflow the strip). + * - When `pixelCount > total`: same per-item pixel spans as the fill’s upsample + * branch; first item top-aligned, last bottom-aligned, middle centered in bucket. + * - Zero-height bucket (upsample only): top-aligned `pixelStart` from `pixelSpanForItem`. */ -export function getIndicatorLayout( - trackWidth: number, - indicatorSize: number, - borderWidth: number, -): { totalWidth: number; totalHeight: number; fillWidth: number; fillHeight: number; leftPos: number } { - const visualSize = indicatorSize * 1.25; - const fillWidth = visualSize; - const fillHeight = Math.round(visualSize / 2); // pill = half height - const totalWidth = fillWidth + borderWidth * 2; - const totalHeight = fillHeight + borderWidth * 2; +export function computeIndicatorTopOffsetInFill( + itemIndex: number, + total: number, + pixelCount: number, + indicatorHeight: number +): number { + const pixelRowCount = Math.max(0, Math.floor(pixelCount)); + const indicatorHeightPx = Math.max(0, Math.floor(indicatorHeight)); + const maxTopInFill = Math.max(0, pixelRowCount - indicatorHeightPx); + if (pixelRowCount === 0 || total <= 0) { + return 0; + } - const centerX = trackWidth / 2; - const leftPos = centerX - totalWidth / 2; + const clamp = (x: number) => Math.max(0, Math.min(maxTopInFill, x)); - return { totalWidth, totalHeight, fillWidth, fillHeight, leftPos }; + if (total >= pixelRowCount) { + const pixelRowIndex = pixelRowIndexForItemDownsample(itemIndex, total, pixelRowCount); + return clamp(pixelRowIndex); + } else { + const { pixelStart, pixelEnd } = pixelSpanForItem(itemIndex, total, pixelRowCount); + const bucketHeight = pixelEnd - pixelStart; + + let topOffset: number; + if (bucketHeight <= 0) { + topOffset = pixelStart; + } else if (itemIndex === 0) { + topOffset = pixelStart; + } else if (itemIndex === total - 1) { + topOffset = pixelEnd - indicatorHeightPx; + } else { + topOffset = pixelStart + Math.floor((bucketHeight - indicatorHeightPx) / 2); + } + + return clamp(topOffset); + } } diff --git a/platform/ui-next/src/components/Viewport/ViewportActionCorners.tsx b/platform/ui-next/src/components/Viewport/ViewportActionCorners.tsx index 0758a2f84..ce8c34607 100644 --- a/platform/ui-next/src/components/Viewport/ViewportActionCorners.tsx +++ b/platform/ui-next/src/components/Viewport/ViewportActionCorners.tsx @@ -27,7 +27,7 @@ const locationClasses = { ), [ViewportActionCornersLocations.topRight]: classNames( commonClasses, - 'absolute top-[4px] right-[16px] right-viewport-scrollbar' + 'absolute top-[4px] right-viewport-scrollbar' ), [ViewportActionCornersLocations.bottomLeft]: classNames( commonClasses, @@ -35,7 +35,7 @@ const locationClasses = { ), [ViewportActionCornersLocations.bottomRight]: classNames( commonClasses, - 'absolute bottom-[3px] right-[16px] right-viewport-scrollbar' + 'absolute bottom-[3px] right-viewport-scrollbar' ), [ViewportActionCornersLocations.topMiddle]: classNames( commonClasses, @@ -52,7 +52,7 @@ const locationClasses = { ), [ViewportActionCornersLocations.rightMiddle]: classNames( commonClasses, - 'absolute right-[16px] top-1/2 -translate-y-1/2 right-viewport-scrollbar' + 'absolute top-1/2 -translate-y-1/2 right-viewport-scrollbar' ), }; diff --git a/platform/ui-next/src/components/Viewport/ViewportOverlay.tsx b/platform/ui-next/src/components/Viewport/ViewportOverlay.tsx index 799d1ebad..7a92a9906 100644 --- a/platform/ui-next/src/components/Viewport/ViewportOverlay.tsx +++ b/platform/ui-next/src/components/Viewport/ViewportOverlay.tsx @@ -31,14 +31,12 @@ function ViewportOverlay({ topLeft, topRight, bottomRight, bottomLeft, color = '
{topRight}
{bottomRight}
diff --git a/platform/ui/tailwind.config.js b/platform/ui/tailwind.config.js index 585983bf1..1a9f7820e 100644 --- a/platform/ui/tailwind.config.js +++ b/platform/ui/tailwind.config.js @@ -306,7 +306,7 @@ module.exports = { full: '100%', viewport: '0.5rem', '1/2': '50%', - 'viewport-scrollbar': '1.3rem', + 'viewport-scrollbar': '1.5rem', }), letterSpacing: { tighter: '-0.05em', diff --git a/tests/ContourSegLocking.spec.ts b/tests/ContourSegLocking.spec.ts index 786b54dbb..8c6a85027 100644 --- a/tests/ContourSegLocking.spec.ts +++ b/tests/ContourSegLocking.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, visitStudy } from './utils'; +import { addOHIFGlobalCustomizations, expect, test, visitStudy } from './utils'; import { simulateNormalizedDragOnElement } from './utils/simulateDragOnElement'; const studyInstanceUID = '1.2.840.113619.2.290.3.3767434740.226.1600859119.501'; @@ -63,13 +63,8 @@ test('should not allow contours to be edited when panelSegmentation.disableEditi await page.waitForTimeout(5000); // disable editing of segmentations via the customization service - await page.evaluate(() => { - window.services.customizationService.setGlobalCustomization( - 'panelSegmentation.disableEditing', - { - $set: true, - } - ); + await addOHIFGlobalCustomizations(page, { + 'panelSegmentation.disableEditing': true, }); await DOMOverlayPageObject.viewport.segmentationHydration.yes.click(); @@ -117,13 +112,8 @@ test('should allow contours to be edited when panelSegmentation.disableEditing i await page.waitForTimeout(5000); // disable editing of segmentations via the customization service - await page.evaluate(() => { - window.services.customizationService.setGlobalCustomization( - 'panelSegmentation.disableEditing', - { - $set: false, - } - ); + await addOHIFGlobalCustomizations(page, { + 'panelSegmentation.disableEditing': false, }); await DOMOverlayPageObject.viewport.segmentationHydration.yes.click(); diff --git a/tests/LabelMapSegLocking.spec.ts b/tests/LabelMapSegLocking.spec.ts index 782ad7bcf..b681f3c60 100644 --- a/tests/LabelMapSegLocking.spec.ts +++ b/tests/LabelMapSegLocking.spec.ts @@ -1,4 +1,10 @@ -import { checkForScreenshot, screenShotPaths, test, visitStudy } from './utils'; +import { + addOHIFGlobalCustomizations, + checkForScreenshot, + screenShotPaths, + test, + visitStudy, +} from './utils'; import { press } from './utils/keyboardUtils'; test.beforeEach(async ({ page }) => { @@ -15,13 +21,8 @@ test('should prevent editing of label map segmentations when panelSegmentation.d viewportPageObject, }) => { // disable editing of segmentations via the customization service - await page.evaluate(() => { - window.services.customizationService.setGlobalCustomization( - 'panelSegmentation.disableEditing', - { - $set: true, - } - ); + await addOHIFGlobalCustomizations(page, { + 'panelSegmentation.disableEditing': true, }); await rightPanelPageObject.labelMapSegmentationPanel.select(); @@ -71,13 +72,8 @@ test('should allow editing of label map segmentations when panelSegmentation.dis viewportPageObject, }) => { // disable editing of segmentations via the customization service - await page.evaluate(() => { - window.services.customizationService.setGlobalCustomization( - 'panelSegmentation.disableEditing', - { - $set: false, - } - ); + await addOHIFGlobalCustomizations(page, { + 'panelSegmentation.disableEditing': false, }); await rightPanelPageObject.labelMapSegmentationPanel.select(); diff --git a/tests/TMTVRendering.spec.ts b/tests/TMTVRendering.spec.ts index 138e83246..9a79ed27b 100644 --- a/tests/TMTVRendering.spec.ts +++ b/tests/TMTVRendering.spec.ts @@ -1,5 +1,4 @@ -import { test } from 'playwright-test-coverage'; -import { visitStudy, checkForScreenshot, screenShotPaths } from './utils/index.js'; +import { test, visitStudy, checkForScreenshot, screenShotPaths } from './utils'; test.skip('should render TMTV correctly.', async ({ page }) => { const studyInstanceUID = '1.2.840.113619.2.290.3.3767434740.226.1600859119.501'; diff --git a/tests/Worklist.spec.ts b/tests/Worklist.spec.ts index 8c01e1683..abef2ff6e 100644 --- a/tests/Worklist.spec.ts +++ b/tests/Worklist.spec.ts @@ -1,5 +1,4 @@ -import { test } from 'playwright-test-coverage'; -import { checkForScreenshot, screenShotPaths } from './utils'; +import { test, checkForScreenshot, screenShotPaths } from './utils'; test.beforeEach(async ({ page }) => { await page.goto(`/?datasources=ohif`); diff --git a/tests/mpr2.spec.ts b/tests/mpr2.spec.ts index 03d866cfa..6d73c1c8b 100644 --- a/tests/mpr2.spec.ts +++ b/tests/mpr2.spec.ts @@ -7,11 +7,19 @@ test.beforeEach(async ({ page }) => { await visitStudy(page, studyInstanceUID, mode, 10000); }); -test('should properly display MPR for MR', async ({ page, mainToolbarPageObject }) => { +test('should properly display MPR for MR', async ({ + page, + mainToolbarPageObject, + viewportPageObject, +}) => { await mainToolbarPageObject.waitForVolumeLoad(); await page.getByTestId('side-panel-header-right').click(); // await page.getByTestId('study-browser-thumbnail-no-image').dblclick(); - await checkForScreenshot(page, page, screenShotPaths.mpr2.mprDisplayedCorrectly); + await checkForScreenshot({ + page, + locator: viewportPageObject.grid, + screenshotPath: screenShotPaths.mpr2.mprDisplayedCorrectly, + }); await page.evaluate(() => { // Access cornerstone directly from the window object @@ -35,5 +43,9 @@ test('should properly display MPR for MR', async ({ page, mainToolbarPageObject } }); - await checkForScreenshot(page, page, screenShotPaths.mpr2.mprDisplayedCorrectlyZoomed); + await checkForScreenshot({ + page, + locator: viewportPageObject.grid, + screenshotPath: screenShotPaths.mpr2.mprDisplayedCorrectlyZoomed, + }); }); diff --git a/tests/screenshots/chromium/mpr2.spec.ts/mprDisplayedCorrectly.png b/tests/screenshots/chromium/mpr2.spec.ts/mprDisplayedCorrectly.png index 6faf89d2b..27227adb1 100644 Binary files a/tests/screenshots/chromium/mpr2.spec.ts/mprDisplayedCorrectly.png and b/tests/screenshots/chromium/mpr2.spec.ts/mprDisplayedCorrectly.png differ diff --git a/tests/screenshots/chromium/mpr2.spec.ts/mprDisplayedCorrectlyZoomed.png b/tests/screenshots/chromium/mpr2.spec.ts/mprDisplayedCorrectlyZoomed.png index aa8cb6aac..1fd82a4d3 100644 Binary files a/tests/screenshots/chromium/mpr2.spec.ts/mprDisplayedCorrectlyZoomed.png and b/tests/screenshots/chromium/mpr2.spec.ts/mprDisplayedCorrectlyZoomed.png differ diff --git a/tests/utils/OHIFConfiguration.ts b/tests/utils/OHIFConfiguration.ts index 429e40f96..0abd70988 100644 --- a/tests/utils/OHIFConfiguration.ts +++ b/tests/utils/OHIFConfiguration.ts @@ -1,6 +1,31 @@ -import { Page } from 'playwright-test-coverage'; +import type { Page } from '@playwright/test'; + +// Global per-test configuration skeleton. +// Keep this empty by default and add top-level window.config overrides here when needed. +export const DEFAULT_E2E_OHIF_CONFIGURATION: Record = {}; + +// Global per-test customization baseline. +export const DEFAULT_E2E_OHIF_CUSTOMIZATIONS: Record = { + 'viewportScrollbar.showViewedFill': false, + 'viewportScrollbar.showLoadingPattern': false, + 'viewportScrollbar.showLoadedFill': false, + 'viewportScrollbar.showLoadedEndpoints': false, +}; export async function addOHIFConfiguration(page: Page, configToAdd: Record) { + const customizationSetters = Object.fromEntries( + Object.entries(DEFAULT_E2E_OHIF_CUSTOMIZATIONS || {}).map(([key, value]) => [ + key, + { $set: value }, + ]) + ); + + const baselinePlusConfigToAdd = { + ...DEFAULT_E2E_OHIF_CONFIGURATION, + customizationService: [customizationSetters], + ...configToAdd, + }; + await page.addInitScript(config => { let _config; Object.defineProperty(window, 'config', { @@ -15,5 +40,21 @@ export async function addOHIFConfiguration(page: Page, configToAdd: Record +) { + await page.evaluate(customizations => { + const customizationService = (window as any).services?.customizationService; + if (!customizationService?.setGlobalCustomization) { + return; + } + + Object.entries(customizations || {}).forEach(([key, value]) => { + customizationService.setGlobalCustomization(key, value); + }); + }, customizationsToAdd); } diff --git a/tests/utils/fixture.ts b/tests/utils/fixture.ts index 007768deb..2ab26e463 100644 --- a/tests/utils/fixture.ts +++ b/tests/utils/fixture.ts @@ -1,4 +1,5 @@ import { test as base } from 'playwright-test-coverage'; +import { addOHIFConfiguration } from './OHIFConfiguration'; import { DOMOverlayPageObject, MainToolbarPageObject, @@ -17,7 +18,18 @@ type PageObjects = { notFoundStudyPageObject: NotFoundStudyPageObject; }; -export const test = base.extend({ +type TestFixtures = PageObjects & { + _applyGlobalE2EOHIFBaseline: void; +}; + +export const test = base.extend({ + _applyGlobalE2EOHIFBaseline: [ + async ({ page }, use) => { + await addOHIFConfiguration(page, {}); + await use(); + }, + { auto: true }, + ], DOMOverlayPageObject: async ({ page }, use) => { await use(new DOMOverlayPageObject(page)); }, diff --git a/tests/utils/index.ts b/tests/utils/index.ts index 7ed55bade..c15967fa8 100644 --- a/tests/utils/index.ts +++ b/tests/utils/index.ts @@ -1,5 +1,8 @@ import { visitStudy } from './visitStudy'; -import { addOHIFConfiguration } from './OHIFConfiguration'; +import { + addOHIFConfiguration, + addOHIFGlobalCustomizations, +} from './OHIFConfiguration'; import { checkForScreenshot } from './checkForScreenshot'; import { screenShotPaths } from './screenShotPaths'; import { @@ -25,6 +28,7 @@ import { subscribeToMeasurementAdded } from './subscribeToMeasurement'; export { visitStudy, addOHIFConfiguration, + addOHIFGlobalCustomizations, checkForScreenshot, screenShotPaths, simulateClicksOnElement,