feat(slice scrollbar): Integrate ViewportSliceProgressScrollbar with customizations and docs (#5960)

Co-authored-by: Dan Rukas <dan.rukas@gmail.com>
This commit is contained in:
Joe Boccanfuso 2026-04-23 08:46:03 -04:00 committed by GitHub
parent 8a2fee4d8a
commit 8fc0dc16e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1730 additions and 279 deletions

View File

@ -1,13 +1,14 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import ViewportImageScrollbar from './ViewportImageScrollbar'; import ViewportImageScrollbar from './ViewportImageScrollbar';
import ViewportSliceProgressScrollbar from './ViewportSliceProgressScrollbar/ViewportSliceProgressScrollbar';
import CustomizableViewportOverlay from './CustomizableViewportOverlay'; import CustomizableViewportOverlay from './CustomizableViewportOverlay';
import ViewportOrientationMarkers from './ViewportOrientationMarkers'; import ViewportOrientationMarkers from './ViewportOrientationMarkers';
import ViewportImageSliceLoadingIndicator from './ViewportImageSliceLoadingIndicator'; import ViewportImageSliceLoadingIndicator from './ViewportImageSliceLoadingIndicator';
function CornerstoneOverlays(props: withAppTypes) { function CornerstoneOverlays(props: withAppTypes) {
const { viewportId, element, scrollbarHeight, servicesManager } = props; const { viewportId, element, scrollbarHeight, servicesManager } = props;
const { cornerstoneViewportService } = servicesManager.services; const { cornerstoneViewportService, customizationService } = servicesManager.services;
const [imageSliceData, setImageSliceData] = useState({ const [imageSliceData, setImageSliceData] = useState({
imageIndex: 0, imageIndex: 0,
numberOfSlices: 0, numberOfSlices: 0,
@ -43,17 +44,31 @@ function CornerstoneOverlays(props: withAppTypes) {
} }
} }
const viewportScrollbarVariant = customizationService.getCustomization('viewportScrollbar.variant');
const useProgressScrollbar = viewportScrollbarVariant !== 'legacy';
return ( return (
<div className="noselect"> <div className="noselect">
<ViewportImageScrollbar {useProgressScrollbar ? (
viewportId={viewportId} <ViewportSliceProgressScrollbar
viewportData={viewportData} viewportId={viewportId}
element={element} viewportData={viewportData}
imageSliceData={imageSliceData} element={element}
setImageSliceData={setImageSliceData} imageSliceData={imageSliceData}
scrollbarHeight={scrollbarHeight} setImageSliceData={setImageSliceData}
servicesManager={servicesManager} servicesManager={servicesManager}
/> />
) : (
<ViewportImageScrollbar
viewportId={viewportId}
viewportData={viewportData}
element={element}
imageSliceData={imageSliceData}
setImageSliceData={setImageSliceData}
scrollbarHeight={scrollbarHeight}
servicesManager={servicesManager}
/>
)}
<CustomizableViewportOverlay <CustomizableViewportOverlay
imageSliceData={imageSliceData} imageSliceData={imageSliceData}

View File

@ -367,7 +367,7 @@ function OverlayItem(props) {
title={title} title={title}
> >
{label ? <span className="mr-1 shrink-0">{label}</span> : null} {label ? <span className="mr-1 shrink-0">{label}</span> : null}
<span className="ml-0 mr-2 shrink-0">{value}</span> <span className="ml-0 shrink-0">{value}</span>
</div> </div>
); );
} }

View File

@ -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<string, number>();
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 (
<div
style={{
position: 'absolute',
right: 0,
top: 0,
height: '100%',
padding: '8px 5px',
zIndex: 10,
}}
>
<div
style={{
position: 'relative',
height: '100%',
width: '11px',
}}
>
<SmartScrollbar
className="absolute inset-0"
value={imageIndex || 0}
total={numberOfSlices}
onValueChange={onScrollbarValueChange}
isLoading={isLoading}
enableKeyboardNavigation={false}
aria-label="Image navigation scrollbar"
indicator={
customizationService.getCustomization('viewportScrollbar.indicator') as
| Record<string, unknown>
| undefined
}
>
<SmartScrollbarTrack>
{isFullMode && showLoadedFill && (
<SmartScrollbarFill
marked={loadedBytes}
version={loadedVersion}
className="bg-neutral/25"
loadingClassName="bg-neutral/50"
/>
)}
{isFullMode && showViewedFill && (
<SmartScrollbarFill
marked={viewedBytes}
version={viewedVersion}
className="bg-primary/35"
loadingClassName="bg-primary/35"
/>
)}
</SmartScrollbarTrack>
<SmartScrollbarIndicator />
{isFullMode && showLoadedEndpoints && (
<SmartScrollbarEndpoints
marked={loadedBytes}
version={loadedVersion}
/>
)}
</SmartScrollbar>
</div>
</div>
);
}
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;

View File

@ -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;
}

View File

@ -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<number[] | null>(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<string, number>;
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<string, number>;
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;
}

View File

@ -0,0 +1 @@
export { default } from './ViewportSliceProgressScrollbar';

View File

@ -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;
};

View File

@ -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': {},
};
}

View File

@ -11,6 +11,7 @@ import windowLevelPresetsCustomization from './customizations/windowLevelPresets
import miscCustomization from './customizations/miscCustomization'; import miscCustomization from './customizations/miscCustomization';
import captureViewportModalCustomization from './customizations/captureViewportModalCustomization'; import captureViewportModalCustomization from './customizations/captureViewportModalCustomization';
import viewportDownloadWarningCustomization from './customizations/viewportDownloadWarningCustomization'; import viewportDownloadWarningCustomization from './customizations/viewportDownloadWarningCustomization';
import getViewportScrollbarCustomization from './customizations/viewportScrollbarCustomization';
function getCustomizationModule({ commandsManager, servicesManager, extensionManager }) { function getCustomizationModule({ commandsManager, servicesManager, extensionManager }) {
return [ return [
@ -34,6 +35,7 @@ function getCustomizationModule({ commandsManager, servicesManager, extensionMan
...miscCustomization, ...miscCustomization,
...captureViewportModalCustomization, ...captureViewportModalCustomization,
...viewportDownloadWarningCustomization, ...viewportDownloadWarningCustomization,
...getViewportScrollbarCustomization(),
}, },
}, },
]; ];

View File

@ -21,6 +21,7 @@ import SegmentationService from './services/SegmentationService';
import CornerstoneCacheService from './services/CornerstoneCacheService'; import CornerstoneCacheService from './services/CornerstoneCacheService';
import CornerstoneViewportService from './services/ViewportService/CornerstoneViewportService'; import CornerstoneViewportService from './services/ViewportService/CornerstoneViewportService';
import ColorbarService from './services/ColorbarService'; import ColorbarService from './services/ColorbarService';
import ViewedDataService from './services/ViewedDataService';
import * as CornerstoneExtensionTypes from './types'; import * as CornerstoneExtensionTypes from './types';
import { toolNames } from './initCornerstoneTools'; import { toolNames } from './initCornerstoneTools';
@ -180,6 +181,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
useSelectedSegmentationsForViewportStore useSelectedSegmentationsForViewportStore
.getState() .getState()
.clearSelectedSegmentationsForViewportStore(); .clearSelectedSegmentationsForViewportStore();
servicesManager.services.viewedDataService?.clearViewedData();
segmentationService.removeAllSegmentations(); segmentationService.removeAllSegmentations();
}, },
@ -196,6 +198,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
servicesManager.registerService(SegmentationService.REGISTRATION); servicesManager.registerService(SegmentationService.REGISTRATION);
servicesManager.registerService(CornerstoneCacheService.REGISTRATION); servicesManager.registerService(CornerstoneCacheService.REGISTRATION);
servicesManager.registerService(ColorbarService.REGISTRATION); servicesManager.registerService(ColorbarService.REGISTRATION);
servicesManager.registerService(ViewedDataService.REGISTRATION);
const { syncGroupService } = servicesManager.services; const { syncGroupService } = servicesManager.services;
syncGroupService.registerCustomSynchronizer('frameview', createFrameViewSynchronizer); syncGroupService.registerCustomSynchronizer('frameview', createFrameViewSynchronizer);

View File

@ -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<string>();
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;

View File

@ -0,0 +1,3 @@
import ViewedDataService from './ViewedDataService';
export default ViewedDataService;

View File

@ -5,6 +5,7 @@ import SegmentationServiceType from '../services/SegmentationService';
import SyncGroupServiceType from '../services/SyncGroupService'; import SyncGroupServiceType from '../services/SyncGroupService';
import ToolGroupServiceType from '../services/ToolGroupService'; import ToolGroupServiceType from '../services/ToolGroupService';
import ColorbarServiceType from '../services/ColorbarService'; import ColorbarServiceType from '../services/ColorbarService';
import ViewedDataServiceType from '../services/ViewedDataService';
import * as cornerstone from '@cornerstonejs/core'; import * as cornerstone from '@cornerstonejs/core';
import * as cornerstoneTools from '@cornerstonejs/tools'; import * as cornerstoneTools from '@cornerstonejs/tools';
@ -23,6 +24,7 @@ declare global {
export type SyncGroupService = SyncGroupServiceType; export type SyncGroupService = SyncGroupServiceType;
export type ToolGroupService = ToolGroupServiceType; export type ToolGroupService = ToolGroupServiceType;
export type ColorbarService = ColorbarServiceType; export type ColorbarService = ColorbarServiceType;
export type ViewedDataService = ViewedDataServiceType;
export interface Services { export interface Services {
cornerstoneViewportService?: CornerstoneViewportServiceType; cornerstoneViewportService?: CornerstoneViewportServiceType;
@ -31,6 +33,7 @@ declare global {
segmentationService?: SegmentationServiceType; segmentationService?: SegmentationServiceType;
cornerstoneCacheService?: CornerstoneCacheServiceType; cornerstoneCacheService?: CornerstoneCacheServiceType;
colorbarService?: ColorbarServiceType; colorbarService?: ColorbarServiceType;
viewedDataService?: ViewedDataServiceType;
} }
export namespace Segmentation { export namespace Segmentation {

View File

@ -471,26 +471,56 @@ Cypress.Commands.add('openPreferences', () => {
}); });
Cypress.Commands.add('scrollToIndex', index => { Cypress.Commands.add('scrollToIndex', index => {
// Workaround implemented based on Cypress issue: cy.get('.cornerstone-viewport-element:visible')
// https://github.com/cypress-io/cypress/issues/1570#issuecomment-450966053 .first()
const nativeInputValueSetter = Object.getOwnPropertyDescriptor( .then($viewportEl => {
window.HTMLInputElement.prototype, cy.window().then(win => {
'value' const cornerstone = win.cornerstone;
).set; const element = $viewportEl[0];
const enabledElement =
cornerstone?.getEnabledElement?.(element) ||
cornerstone?.getEnabledElements?.().find(item => item.element === element);
cy.get('input.imageSlider[type=range]').then($range => { if (!enabledElement?.viewport) {
// get the DOM node throw new Error('scrollToIndex: no enabled cornerstone viewport found');
const range = $range[0]; }
// set the value manually
nativeInputValueSetter.call(range, index); const viewport = enabledElement.viewport;
// now dispatch the event const numberOfSlices = viewport.getNumberOfSlices?.() ?? 0;
range.dispatchEvent(
new Event('change', { if (!numberOfSlices) {
value: index, throw new Error('scrollToIndex: viewport has no slices');
bubbles: true, }
})
); 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', () => { Cypress.Commands.add('closePreferences', () => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View File

@ -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 indicators **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 <MyIndicator />` 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)}

View File

@ -15,6 +15,12 @@ import progressLoading from '../../../assets/img/Loading-Indicator.png';
import loadingIndicatorProgress from '../../../assets/img/loading-indicator-icon.png'; import loadingIndicatorProgress from '../../../assets/img/loading-indicator-icon.png';
import loadingIndicatorPercent from '../../../assets/img/loading-indicator-percent.png'; import loadingIndicatorPercent from '../../../assets/img/loading-indicator-percent.png';
import viewportActionCorners from '../../../assets/img/viewport-action-corners.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 contextMenu from '../../../assets/img/context-menu.jpg';
import viewportDownloadWarning from '../../../assets/img/viewport-download-warning.png'; import viewportDownloadWarning from '../../../assets/img/viewport-download-warning.png';
import segmentationOverlay from '../../../assets/img/segmentation-overlay.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 <code>progress</code> for{' '}
ViewportSliceProgressScrollbar and <code>legacy</code> 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 (<code>totalWidth</code> × <code>totalHeight</code>, border included) and{' '}
<code>renderIndicator</code> for the progress scrollbar indicator.{' '}
<code>renderIndicator</code> receives <code>React</code> for config file compatibility. All
three properties must be provided or the default pill is used. See{' '}
<a href="#viewport-scrollbar-indicator-advanced">Advanced</a> for TSX overrides and{' '}
<code>useSmartScrollbarLayoutContext</code>.
</>
),
default: '{}',
configurationIntro: (
<p style={{ margin: 0 }}>
This example sets a <code>10×10</code> SVG indicator (muted outer circle, lighter inner
circle) via <code>React.createElement</code>, matching <code>totalWidth</code> and{' '}
<code>totalHeight</code>.
</p>
),
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 = [ export const customizations = [
{ {
id: 'ohif.hotkeyBindings', 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[]) => { export const TableGenerator = (customizations: any[]) => {
return customizations.map(({ id, description, default: defaultValue, configuration, image }) => ( return customizations.map(
<div ({ id, description, default: defaultValue, configuration, configurationIntro, image }) => (
key={id} <div
style={{ marginBottom: '2rem', borderRadius: '8px', padding: '1rem' }} key={id}
> style={{ marginBottom: '2rem', borderRadius: '8px', padding: '1rem' }}
<h3
id={id.toLowerCase().replace(/\./g, '')}
style={{ marginBottom: '1rem', fontSize: '1.5rem' }}
> >
{id} <h3
</h3> id={id.toLowerCase().replace(/\./g, '')}
<table style={{ width: '100%', tableLayout: 'fixed' }}> style={{ marginBottom: '1rem', fontSize: '1.5rem' }}
<tbody> >
<tr> {id}
<th style={{ textAlign: 'left', verticalAlign: 'top', width: '20%' }}>ID</th> </h3>
<td style={{ wordBreak: 'break-word' }}> <table style={{ width: '100%', tableLayout: 'fixed' }}>
<code>{id}</code> <tbody>
</td> <tr>
</tr> <th style={{ textAlign: 'left', verticalAlign: 'top', width: '20%' }}>ID</th>
<tr> <td style={{ wordBreak: 'break-word' }}>
<th style={{ textAlign: 'left', verticalAlign: 'top', width: '20%' }}>Description</th> <code>{id}</code>
<td> </td>
<div>{description}</div> </tr>
{image && ( <tr>
<div> <th style={{ textAlign: 'left', verticalAlign: 'top', width: '20%' }}>Description</th>
{Array.isArray(image) ? ( <td>
image.map((img, index) => ( <div>{description}</div>
<Image {image && (
key={index} <div>
img={img} {Array.isArray(image)
alt={`${id}-${index + 1}`} ? image.map((item, index) => {
style={{ width: '400px' }} const { img, caption } = normalizeCustomizationImageItem(item);
/> const altText =
)) typeof caption === 'string' && caption.length > 0
) : ( ? `${id}: ${caption}`
<Image : `${id} screenshot ${index + 1}`;
img={image} return (
alt={id} <figure
style={{ width: '400px' }} key={index}
/> style={{ margin: '0 0 1rem 0' }}
)} >
</div> <Image
)} img={img}
</td> alt={altText}
</tr> style={{ width: '400px' }}
<tr> />
<th style={{ textAlign: 'left', verticalAlign: 'top', width: '20%' }}>Default Value</th> {caption != null && caption !== '' && (
<td style={{ wordBreak: 'break-word' }}> <figcaption
<pre> style={{
{typeof defaultValue === 'string' fontSize: '0.9rem',
? defaultValue marginTop: '0.35rem',
: JSON.stringify(defaultValue, null, 2)} color: 'var(--ifm-color-emphasis-700)',
</pre> }}
</td> >
</tr> {caption}
<tr> </figcaption>
<th style={{ textAlign: 'left', verticalAlign: 'top', width: '20%' }}>Example</th> )}
<td style={{ wordBreak: 'break-word' }}> </figure>
{configuration && ( );
<div> })
<pre> : (() => {
<code>{configuration}</code> const { img, caption } = normalizeCustomizationImageItem(image);
</pre> const altText =
</div> typeof caption === 'string' && caption.length > 0
)} ? `${id}: ${caption}`
</td> : id;
</tr> return (
</tbody> <figure style={{ margin: 0 }}>
</table> <Image
</div> img={img}
)); alt={altText}
style={{ width: '400px' }}
/>
{caption != null && caption !== '' && (
<figcaption
style={{
fontSize: '0.9rem',
marginTop: '0.35rem',
color: 'var(--ifm-color-emphasis-700)',
}}
>
{caption}
</figcaption>
)}
</figure>
);
})()}
</div>
)}
</td>
</tr>
<tr>
<th style={{ textAlign: 'left', verticalAlign: 'top', width: '20%' }}>
Default Value
</th>
<td style={{ wordBreak: 'break-word' }}>
<pre>
{typeof defaultValue === 'string'
? defaultValue
: JSON.stringify(defaultValue, null, 2)}
</pre>
</td>
</tr>
<tr>
<th style={{ textAlign: 'left', verticalAlign: 'top', width: '20%' }}>Example</th>
<td style={{ wordBreak: 'break-word' }}>
{configuration && (
<div>
{configurationIntro && (
<div
style={{
marginBottom: '0.75rem',
fontSize: '0.95rem',
color: 'var(--ifm-color-emphasis-700)',
}}
>
{configurationIntro}
</div>
)}
<pre>
<code>{configuration}</code>
</pre>
</div>
)}
</td>
</tr>
</tbody>
</table>
</div>
)
);
}; };

View File

@ -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<string>` 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.

View File

@ -23,6 +23,7 @@ We maintain the following non-ui Services:
- [Measurement Service](../data/MeasurementService.md) - [Measurement Service](../data/MeasurementService.md)
- [Customization Service](./../customization-service/customizationService.md) - [Customization Service](./../customization-service/customizationService.md)
- [Panel Service](../data/PanelService.md) - [Panel Service](../data/PanelService.md)
- [Viewed Data Service](../data/ViewedDataService.md)
## Service Architecture ## Service Architecture

View File

@ -105,6 +105,17 @@ The following services is available in the `OHIF-v3`.
ToolBarService ToolBarService
</td> </td>
</tr> </tr>
<tr>
<td>
<a href="./data/ViewedDataService">
ViewedDataService
</a>
</td>
<td>Data Service</td>
<td>
viewedDataService
</td>
</tr>
<tr> <tr>
<td> <td>
<a href="./ui/viewport-grid-service"> <a href="./ui/viewport-grid-service">

View File

@ -9,8 +9,8 @@ import React, {
Children, Children,
isValidElement, isValidElement,
} from 'react'; } from 'react';
import { getIndicatorLayout } from './utils';
import { SmartScrollbarIndicator } from './SmartScrollbarIndicator'; import { SmartScrollbarIndicator } from './SmartScrollbarIndicator';
import { DEFAULT_INDICATOR_CONFIG } from './defaultSmartScrollbarIndicatorConfig';
// ── Child validation ──────────────────────────────────────────── // ── Child validation ────────────────────────────────────────────
function validateChildren(children: React.ReactNode): void { function validateChildren(children: React.ReactNode): void {
@ -33,19 +33,56 @@ function validateChildren(children: React.ReactNode): void {
const TRACK_WIDTH = 8; const TRACK_WIDTH = 8;
const RESTING_WIDTH = 4; const RESTING_WIDTH = 4;
const FILL_PADDING = 3; const FILL_PADDING = 3;
const INDICATOR_SIZE = 8;
const INDICATOR_BORDER_WIDTH = 1;
const SETTLE_DELAY = 600; 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<string, unknown> | 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 ─────────────────────────────────────────────────── // ── Contexts ───────────────────────────────────────────────────
export interface SmartScrollbarLayoutContextValue { export interface SmartScrollbarLayoutContextValue {
total: number; total: number;
trackHeight: number; trackHeight: number;
isLoading: boolean; isLoading: boolean;
isDragging: boolean;
effectiveWidth: number; effectiveWidth: number;
trackWidth: number; trackWidth: number;
fillPadding: number; fillPadding: number;
stableLayerEl: HTMLDivElement | null; stableLayerEl: HTMLDivElement | null;
indicatorTotalWidth: number;
indicatorTotalHeight: number;
renderIndicator: (react: typeof React) => React.ReactNode;
} }
const SmartScrollbarLayoutContext = createContext<SmartScrollbarLayoutContextValue | null>(null); const SmartScrollbarLayoutContext = createContext<SmartScrollbarLayoutContextValue | null>(null);
@ -74,6 +111,10 @@ interface SmartScrollbarProps {
enableKeyboardNavigation?: boolean; enableKeyboardNavigation?: boolean;
'aria-label'?: string; 'aria-label'?: string;
className?: string; className?: string;
/**
* Indicator payload parsed inside the component.
*/
indicator?: Record<string, unknown>;
children: React.ReactNode; children: React.ReactNode;
} }
@ -86,10 +127,24 @@ export function SmartScrollbar({
enableKeyboardNavigation = false, enableKeyboardNavigation = false,
'aria-label': ariaLabel = 'Scroll position', 'aria-label': ariaLabel = 'Scroll position',
className, className,
indicator,
children, children,
}: SmartScrollbarProps) { }: SmartScrollbarProps) {
validateChildren(children); 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 ─────────────────────────── // ── ResizeObserver for trackHeight ───────────────────────────
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [trackHeight, setTrackHeight] = useState(0); const [trackHeight, setTrackHeight] = useState(0);
@ -97,6 +152,12 @@ export function SmartScrollbar({
useEffect(() => { useEffect(() => {
const el = containerRef.current; const el = containerRef.current;
if (!el) return; 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]) => { const ro = new ResizeObserver(([entry]) => {
setTrackHeight(entry.contentRect.height); setTrackHeight(entry.contentRect.height);
}); });
@ -127,9 +188,8 @@ export function SmartScrollbar({
const isExpanded = !hasSettled || isHovered || isDragging; const isExpanded = !hasSettled || isHovered || isDragging;
const effectiveWidth = isExpanded ? TRACK_WIDTH : RESTING_WIDTH; const effectiveWidth = isExpanded ? TRACK_WIDTH : RESTING_WIDTH;
// ── Hit zone extension ─────────────────────────────────────── // ── Hit zone: extend past `TRACK_WIDTH` on both sides when the pill is wider than the track ──
const { leftPos } = getIndicatorLayout(TRACK_WIDTH, INDICATOR_SIZE, INDICATOR_BORDER_WIDTH); const hitZoneSideExtension = Math.max(0, resolvedIndicator.totalWidth / 2 - TRACK_WIDTH / 2);
const hitZoneLeftExtension = Math.max(0, -leftPos);
// ── Stable layer (for elements that shouldn't move during contraction) ── // ── Stable layer (for elements that shouldn't move during contraction) ──
// Uses useState + callback ref so React triggers a re-render when the // Uses useState + callback ref so React triggers a re-render when the
@ -137,14 +197,21 @@ export function SmartScrollbar({
const [stableLayerEl, setStableLayerEl] = useState<HTMLDivElement | null>(null); const [stableLayerEl, setStableLayerEl] = useState<HTMLDivElement | null>(null);
// ── Pointer helpers ────────────────────────────────────────── // ── Pointer helpers ──────────────────────────────────────────
const clamp = useCallback( const clamp = useCallback((val: number) => Math.max(0, Math.min(total - 1, val)), [total]);
(val: number) => Math.max(0, Math.min(total - 1, val)),
[total]
);
const indexFromPointerY = useCallback( const indexFromPointerY = useCallback(
(clientY: number) => { (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)); return Math.round(ratio * (total - 1));
}, },
[trackHeight, total] [trackHeight, total]
@ -153,6 +220,9 @@ export function SmartScrollbar({
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
trackTopRef.current = e.currentTarget.getBoundingClientRect().top; trackTopRef.current = e.currentTarget.getBoundingClientRect().top;
if (trackHeight <= 0) {
return;
}
isDraggingRef.current = true; isDraggingRef.current = true;
setIsDragging(true); setIsDragging(true);
@ -160,7 +230,7 @@ export function SmartScrollbar({
onValueChange(clamp(indexFromPointerY(e.clientY))); onValueChange(clamp(indexFromPointerY(e.clientY)));
}, },
[clamp, indexFromPointerY, onValueChange] [clamp, indexFromPointerY, onValueChange, trackHeight]
); );
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
@ -216,15 +286,32 @@ export function SmartScrollbar({
); );
// ── Context values ─────────────────────────────────────────── // ── Context values ───────────────────────────────────────────
const layoutCtx = useMemo<SmartScrollbarLayoutContextValue>(() => ({ const layoutCtx = useMemo<SmartScrollbarLayoutContextValue>(
total, () => ({
trackHeight, total,
isLoading, trackHeight,
effectiveWidth, isLoading,
trackWidth: TRACK_WIDTH, isDragging,
fillPadding: FILL_PADDING, effectiveWidth,
stableLayerEl, trackWidth: TRACK_WIDTH,
}), [total, trackHeight, isLoading, effectiveWidth, stableLayerEl]); 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 ( return (
<SmartScrollbarLayoutContext.Provider value={layoutCtx}> <SmartScrollbarLayoutContext.Provider value={layoutCtx}>
<SmartScrollbarScrollContext.Provider value={value}> <SmartScrollbarScrollContext.Provider value={value}>
@ -239,10 +326,10 @@ export function SmartScrollbar({
tabIndex={0} tabIndex={0}
className={className} className={className}
style={{ style={{
width: TRACK_WIDTH + hitZoneLeftExtension, width: TRACK_WIDTH + hitZoneSideExtension * 2,
height: '100%', height: '100%',
position: 'relative', position: 'relative',
marginLeft: -hitZoneLeftExtension, marginLeft: -hitZoneSideExtension,
cursor: isDragging ? 'grabbing' : 'grab', cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none', touchAction: 'none',
}} }}
@ -258,7 +345,7 @@ export function SmartScrollbar({
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
right: 0, right: hitZoneSideExtension,
top: 0, top: 0,
width: TRACK_WIDTH, width: TRACK_WIDTH,
height: trackHeight, height: trackHeight,

View File

@ -11,15 +11,17 @@ interface SmartScrollbarFillProps {
* Recommended: manage `marked` + `version` together via `useByteArray()`. * Recommended: manage `marked` + `version` together via `useByteArray()`.
*/ */
version: number; version: number;
/** Fill color class. Defaults to `'bg-neutral/25'`. Override to customize (e.g. `'bg-primary/35'` for a viewed fill). */
className?: string; className?: string;
/** Fill color class used while the scrollbar is in a loading state. Defaults to `'bg-neutral/50'`. */
loadingClassName?: string; loadingClassName?: string;
} }
export const SmartScrollbarFill = React.memo(function SmartScrollbarFill({ export const SmartScrollbarFill = React.memo(function SmartScrollbarFill({
marked, marked,
version, version,
className, className = 'bg-neutral/25',
loadingClassName, loadingClassName = 'bg-neutral/50',
}: SmartScrollbarFillProps) { }: SmartScrollbarFillProps) {
const { trackHeight, effectiveWidth, fillPadding, isLoading } = useSmartScrollbarLayoutContext(); const { trackHeight, effectiveWidth, fillPadding, isLoading } = useSmartScrollbarLayoutContext();

View File

@ -1,79 +1,59 @@
import React from 'react'; import React from 'react';
import { useSmartScrollbarLayoutContext, useSmartScrollbarScrollContext } from './SmartScrollbar'; import { useSmartScrollbarLayoutContext, useSmartScrollbarScrollContext } from './SmartScrollbar';
import { getIndicatorLayout } from './utils'; import { computeIndicatorTopOffsetInFill } from './utils';
// ── Indicator dimensions and colors ─────────────────────────────
const INDICATOR_SIZE = 8;
const BORDER_WIDTH = 1;
const INDICATOR_COLOR = 'hsl(var(--foreground) / 0.9)';
const BORDER_COLOR = 'hsl(var(--neutral) / 0.9)';
interface SmartScrollbarIndicatorProps { interface SmartScrollbarIndicatorProps {
className?: string; className?: string;
} }
export function SmartScrollbarIndicator({ className }: SmartScrollbarIndicatorProps) { export function SmartScrollbarIndicator({ className }: SmartScrollbarIndicatorProps) {
const { total, trackHeight, effectiveWidth, fillPadding } = const {
useSmartScrollbarLayoutContext(); total,
trackHeight,
effectiveWidth,
fillPadding,
indicatorTotalWidth,
indicatorTotalHeight,
renderIndicator,
} = useSmartScrollbarLayoutContext();
const value = useSmartScrollbarScrollContext(); const value = useSmartScrollbarScrollContext();
if (trackHeight === 0 || total <= 1) return null; if (trackHeight === 0 || total <= 1) {
return null;
}
const { totalWidth, totalHeight, fillWidth, fillHeight, leftPos } = getIndicatorLayout( // Horizontal: center on the contracting inner width (not the full physical track).
effectiveWidth, const leftPos = effectiveWidth / 2 - indicatorTotalWidth / 2;
INDICATOR_SIZE,
BORDER_WIDTH
);
const offsetY = (totalHeight - INDICATOR_SIZE) / 2;
const fillAreaTop = fillPadding; const fillAreaTop = fillPadding;
// Vertical: same fill strip as SmartScrollbarFill — full track height minus padding.
const pixelCount = Math.max(0, Math.floor(trackHeight - fillPadding * 2)); const pixelCount = Math.max(0, Math.floor(trackHeight - fillPadding * 2));
if (pixelCount === 0) return null; if (pixelCount === 0) {
return null;
}
// Align the indicator with the items pixel bucket(s) so it sits “over” the
// same pixel-space mapping used by fill/endpoints.
const clampedValue = Math.max(0, Math.min(total - 1, value)); const clampedValue = Math.max(0, Math.min(total - 1, value));
const itemStartPx = Math.floor((clampedValue * pixelCount) / total); const topOffsetInFill = computeIndicatorTopOffsetInFill(
const maxTopInFill = Math.max(0, pixelCount - INDICATOR_SIZE); clampedValue,
const topInFill = Math.max(0, Math.min(maxTopInFill, itemStartPx)); total,
const y = fillAreaTop + topInFill; pixelCount,
indicatorTotalHeight
);
const topPos = fillAreaTop + topOffsetInFill;
return ( return (
<div <div
className={`pointer-events-none absolute ${className ?? ''}`} className={`pointer-events-none absolute ${className ?? ''}`}
style={{ style={{
left: leftPos, left: leftPos,
top: y - offsetY, top: topPos,
width: totalWidth, width: indicatorTotalWidth,
height: totalHeight, height: indicatorTotalHeight,
transition: 'left 300ms ease, opacity 300ms ease', transition: 'left 300ms ease, opacity 300ms ease',
}} }}
> >
<svg {renderIndicator(React)}
width={totalWidth}
height={totalHeight}
viewBox={`0 0 ${totalWidth} ${totalHeight}`}
>
{/* Border rect */}
<rect
x={0}
y={0}
width={totalWidth}
height={totalHeight}
rx={totalHeight / 2}
ry={totalHeight / 2}
fill={BORDER_COLOR}
/>
{/* Fill rect */}
<rect
x={BORDER_WIDTH}
y={BORDER_WIDTH}
width={fillWidth}
height={fillHeight}
rx={fillHeight / 2}
ry={fillHeight / 2}
fill={INDICATOR_COLOR}
/>
</svg>
</div> </div>
); );
} }

View File

@ -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 (
<svg
width={TOTAL_WIDTH}
height={TOTAL_HEIGHT}
viewBox={`0 0 ${TOTAL_WIDTH} ${TOTAL_HEIGHT}`}
>
<rect
x={0}
y={0}
width={TOTAL_WIDTH}
height={TOTAL_HEIGHT}
rx={TOTAL_HEIGHT / 2}
ry={TOTAL_HEIGHT / 2}
fill={BORDER_COLOR}
/>
<rect
x={borderWidth}
y={borderWidth}
width={fillWidth}
height={fillHeight}
rx={fillHeight / 2}
ry={fillHeight / 2}
fill={INDICATOR_COLOR}
/>
</svg>
);
}
export const DEFAULT_INDICATOR_CONFIG: SmartScrollbarIndicatorConfig = {
totalWidth: TOTAL_WIDTH,
totalHeight: TOTAL_HEIGHT,
renderIndicator: (_react: typeof React) => <DefaultIndicator />,
};

View File

@ -1,5 +1,8 @@
export { SmartScrollbar, useSmartScrollbarLayoutContext, useSmartScrollbarScrollContext } from './SmartScrollbar'; export {
export type { SmartScrollbarLayoutContextValue } from './SmartScrollbar'; SmartScrollbar,
useSmartScrollbarLayoutContext,
useSmartScrollbarScrollContext,
} from './SmartScrollbar';
export { SmartScrollbarTrack } from './SmartScrollbarTrack'; export { SmartScrollbarTrack } from './SmartScrollbarTrack';
export { SmartScrollbarFill } from './SmartScrollbarFill'; export { SmartScrollbarFill } from './SmartScrollbarFill';
export { SmartScrollbarIndicator } from './SmartScrollbarIndicator'; export { SmartScrollbarIndicator } from './SmartScrollbarIndicator';

View File

@ -1,5 +1,4 @@
import debounce from 'lodash.debounce'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
export interface ByteArrayHandle { export interface ByteArrayHandle {
bytes: Uint8Array; bytes: Uint8Array;
@ -16,45 +15,68 @@ export interface ByteArrayHandle {
* detection via an incrementing version counter. * detection via an incrementing version counter.
* *
* @param size - Number of positions (e.g. total slices in a viewport). * @param size - Number of positions (e.g. total slices in a viewport).
* @param debounceMs - When > 0, version bumps are debounced by this many * @param batchIntervalMs - When > 0, writes are coalesced into a scheduled
* milliseconds. Byte writes are always immediate. Use for * flush: the first write starts a timer, the next
* high-frequency sources (cache prefetch) to batch renders. * flush bumps `version`, and the timer stops. New
* Omit or pass 0 for immediate re-renders (e.g. viewed tracking). * 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 bytesRef = useRef(new Uint8Array(size));
const countRef = useRef(0); const countRef = useRef(0);
const [version, setVersion] = useState(0); const [version, setVersion] = useState(0);
const timeoutIdRef = useRef<number | null>(null);
// Debounced bump — recreated when debounceMs changes; cancelled on unmount const clearScheduledFlush = useCallback(() => {
// or when debounceMs changes, following the lodash.debounce pattern used if (timeoutIdRef.current !== null) {
// throughout ui-next (InputFilter, CinePlayer). window.clearTimeout(timeoutIdRef.current);
const debouncedBump = useMemo( timeoutIdRef.current = null;
() => (debounceMs > 0 ? debounce(() => setVersion(v => v + 1), debounceMs) : null), }
[debounceMs] }, []);
);
useEffect(() => { const flushScheduledVersion = useCallback(() => {
return () => debouncedBump?.cancel(); // End this timeout window after the scheduled flush.
}, [debouncedBump]); clearScheduledFlush();
setVersion(v => v + 1);
}, [clearScheduledFlush]);
// Reset array only when size actually changes — skip on initial mount since // Reset array only when size actually changes — skip on initial mount since
// bytesRef is already initialised to the correct size via useRef. // bytesRef is already initialised to the correct size via useRef.
useEffect(() => { useEffect(() => {
if (bytesRef.current.length === size) return; 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); bytesRef.current = new Uint8Array(size);
countRef.current = 0; countRef.current = 0;
setVersion(v => v + 1); 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(() => { const bump = useCallback(() => {
if (debouncedBump) { if (batchIntervalMs <= 0) {
debouncedBump();
} else {
setVersion(v => v + 1); setVersion(v => v + 1);
return;
} }
}, [debouncedBump]);
if (timeoutIdRef.current === null) {
timeoutIdRef.current = window.setTimeout(flushScheduledVersion, batchIntervalMs);
}
}, [batchIntervalMs, flushScheduledVersion]);
const setByte = useCallback( const setByte = useCallback(
(index: number) => { (index: number) => {

View File

@ -1,4 +1,8 @@
import { computeContiguousRuns, computePixelFilledFromMarked } from './utils'; import {
computeContiguousRuns,
computeIndicatorTopOffsetInFill,
computePixelFilledFromMarked,
} from './utils';
function u8(values: number[]): Uint8Array { function u8(values: number[]): Uint8Array {
return new Uint8Array(values); 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', () => { describe('computeContiguousRuns', () => {
it('returns [] for empty input', () => { it('returns [] for empty input', () => {
expect(computeContiguousRuns(new Uint8Array(0))).toEqual([]); expect(computeContiguousRuns(new Uint8Array(0))).toEqual([]);

View File

@ -26,6 +26,52 @@ export function computeContiguousRuns(bytes: Uint8Array): ContiguousRun[] {
return runs; 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). * 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 * The result is conservative in the sense that a pixel row is filled only when
@ -41,20 +87,19 @@ export function computePixelFilledFromMarked(
pixelCount: number pixelCount: number
): Uint8Array { ): Uint8Array {
const total = marked.length; const total = marked.length;
const count = Math.max(0, Math.floor(pixelCount)); const pixelRowCount = Math.max(0, Math.floor(pixelCount));
if (count === 0 || total <= 0) return new Uint8Array(0); if (pixelRowCount === 0 || total <= 0) return new Uint8Array(0);
const pixelFilled = new Uint8Array(count); const pixelFilled = new Uint8Array(pixelRowCount);
if (total >= count) { if (total >= pixelRowCount) {
for (let pixelIndex = 0; pixelIndex < count; pixelIndex++) { for (let pixelIndex = 0; pixelIndex < pixelRowCount; pixelIndex++) {
const start = Math.floor((pixelIndex * total) / count); const { itemStart, itemEnd } = itemRangeForPixelRow(pixelIndex, total, pixelRowCount);
const end = Math.floor(((pixelIndex + 1) * total) / count); if (itemEnd <= itemStart) continue;
if (end <= start) continue;
let filled = 1; let filled = 1;
for (let itemIndex = start; itemIndex < end; itemIndex++) { for (let i = itemStart; i < itemEnd; i++) {
if (marked[itemIndex] === 0) { if (marked[i] === 0) {
filled = 0; filled = 0;
break; break;
} }
@ -64,9 +109,8 @@ export function computePixelFilledFromMarked(
} else { } else {
for (let itemIndex = 0; itemIndex < total; itemIndex++) { for (let itemIndex = 0; itemIndex < total; itemIndex++) {
if (marked[itemIndex] === 0) continue; if (marked[itemIndex] === 0) continue;
const topPx = Math.floor((itemIndex * count) / total); const { pixelStart, pixelEnd } = pixelSpanForItem(itemIndex, total, pixelRowCount);
const bottomPx = Math.floor(((itemIndex + 1) * count) / total); for (let pixel = pixelStart; pixel < pixelEnd; pixel++) {
for (let pixel = topPx; pixel < bottomPx; pixel++) {
pixelFilled[pixel] = 1; pixelFilled[pixel] = 1;
} }
} }
@ -76,22 +120,49 @@ export function computePixelFilledFromMarked(
} }
/** /**
* Compute the indicator's total visual dimensions and horizontal position. * Vertical offset (px) of the indicator within the fill strip `[0, pixelCount)`,
* Design 27: pill shape, center position, 1px border. * 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 fills 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( export function computeIndicatorTopOffsetInFill(
trackWidth: number, itemIndex: number,
indicatorSize: number, total: number,
borderWidth: number, pixelCount: number,
): { totalWidth: number; totalHeight: number; fillWidth: number; fillHeight: number; leftPos: number } { indicatorHeight: number
const visualSize = indicatorSize * 1.25; ): number {
const fillWidth = visualSize; const pixelRowCount = Math.max(0, Math.floor(pixelCount));
const fillHeight = Math.round(visualSize / 2); // pill = half height const indicatorHeightPx = Math.max(0, Math.floor(indicatorHeight));
const totalWidth = fillWidth + borderWidth * 2; const maxTopInFill = Math.max(0, pixelRowCount - indicatorHeightPx);
const totalHeight = fillHeight + borderWidth * 2; if (pixelRowCount === 0 || total <= 0) {
return 0;
}
const centerX = trackWidth / 2; const clamp = (x: number) => Math.max(0, Math.min(maxTopInFill, x));
const leftPos = centerX - totalWidth / 2;
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);
}
} }

View File

@ -27,7 +27,7 @@ const locationClasses = {
), ),
[ViewportActionCornersLocations.topRight]: classNames( [ViewportActionCornersLocations.topRight]: classNames(
commonClasses, commonClasses,
'absolute top-[4px] right-[16px] right-viewport-scrollbar' 'absolute top-[4px] right-viewport-scrollbar'
), ),
[ViewportActionCornersLocations.bottomLeft]: classNames( [ViewportActionCornersLocations.bottomLeft]: classNames(
commonClasses, commonClasses,
@ -35,7 +35,7 @@ const locationClasses = {
), ),
[ViewportActionCornersLocations.bottomRight]: classNames( [ViewportActionCornersLocations.bottomRight]: classNames(
commonClasses, commonClasses,
'absolute bottom-[3px] right-[16px] right-viewport-scrollbar' 'absolute bottom-[3px] right-viewport-scrollbar'
), ),
[ViewportActionCornersLocations.topMiddle]: classNames( [ViewportActionCornersLocations.topMiddle]: classNames(
commonClasses, commonClasses,
@ -52,7 +52,7 @@ const locationClasses = {
), ),
[ViewportActionCornersLocations.rightMiddle]: classNames( [ViewportActionCornersLocations.rightMiddle]: classNames(
commonClasses, 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'
), ),
}; };

View File

@ -31,14 +31,12 @@ function ViewportOverlay({ topLeft, topRight, bottomRight, bottomLeft, color = '
<div <div
data-cy="viewport-overlay-top-right" data-cy="viewport-overlay-top-right"
className={classNames(overlay, classes.topRight)} className={classNames(overlay, classes.topRight)}
style={{ transform: 'translateX(9px)' }}
> >
{topRight} {topRight}
</div> </div>
<div <div
data-cy="viewport-overlay-bottom-right" data-cy="viewport-overlay-bottom-right"
className={classNames(overlay, classes.bottomRight)} className={classNames(overlay, classes.bottomRight)}
style={{ transform: 'translateX(6px)' }}
> >
{bottomRight} {bottomRight}
</div> </div>

View File

@ -306,7 +306,7 @@ module.exports = {
full: '100%', full: '100%',
viewport: '0.5rem', viewport: '0.5rem',
'1/2': '50%', '1/2': '50%',
'viewport-scrollbar': '1.3rem', 'viewport-scrollbar': '1.5rem',
}), }),
letterSpacing: { letterSpacing: {
tighter: '-0.05em', tighter: '-0.05em',

View File

@ -1,4 +1,4 @@
import { expect, test, visitStudy } from './utils'; import { addOHIFGlobalCustomizations, expect, test, visitStudy } from './utils';
import { simulateNormalizedDragOnElement } from './utils/simulateDragOnElement'; import { simulateNormalizedDragOnElement } from './utils/simulateDragOnElement';
const studyInstanceUID = '1.2.840.113619.2.290.3.3767434740.226.1600859119.501'; 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); await page.waitForTimeout(5000);
// disable editing of segmentations via the customization service // disable editing of segmentations via the customization service
await page.evaluate(() => { await addOHIFGlobalCustomizations(page, {
window.services.customizationService.setGlobalCustomization( 'panelSegmentation.disableEditing': true,
'panelSegmentation.disableEditing',
{
$set: true,
}
);
}); });
await DOMOverlayPageObject.viewport.segmentationHydration.yes.click(); 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); await page.waitForTimeout(5000);
// disable editing of segmentations via the customization service // disable editing of segmentations via the customization service
await page.evaluate(() => { await addOHIFGlobalCustomizations(page, {
window.services.customizationService.setGlobalCustomization( 'panelSegmentation.disableEditing': false,
'panelSegmentation.disableEditing',
{
$set: false,
}
);
}); });
await DOMOverlayPageObject.viewport.segmentationHydration.yes.click(); await DOMOverlayPageObject.viewport.segmentationHydration.yes.click();

View File

@ -1,4 +1,10 @@
import { checkForScreenshot, screenShotPaths, test, visitStudy } from './utils'; import {
addOHIFGlobalCustomizations,
checkForScreenshot,
screenShotPaths,
test,
visitStudy,
} from './utils';
import { press } from './utils/keyboardUtils'; import { press } from './utils/keyboardUtils';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@ -15,13 +21,8 @@ test('should prevent editing of label map segmentations when panelSegmentation.d
viewportPageObject, viewportPageObject,
}) => { }) => {
// disable editing of segmentations via the customization service // disable editing of segmentations via the customization service
await page.evaluate(() => { await addOHIFGlobalCustomizations(page, {
window.services.customizationService.setGlobalCustomization( 'panelSegmentation.disableEditing': true,
'panelSegmentation.disableEditing',
{
$set: true,
}
);
}); });
await rightPanelPageObject.labelMapSegmentationPanel.select(); await rightPanelPageObject.labelMapSegmentationPanel.select();
@ -71,13 +72,8 @@ test('should allow editing of label map segmentations when panelSegmentation.dis
viewportPageObject, viewportPageObject,
}) => { }) => {
// disable editing of segmentations via the customization service // disable editing of segmentations via the customization service
await page.evaluate(() => { await addOHIFGlobalCustomizations(page, {
window.services.customizationService.setGlobalCustomization( 'panelSegmentation.disableEditing': false,
'panelSegmentation.disableEditing',
{
$set: false,
}
);
}); });
await rightPanelPageObject.labelMapSegmentationPanel.select(); await rightPanelPageObject.labelMapSegmentationPanel.select();

View File

@ -1,5 +1,4 @@
import { test } from 'playwright-test-coverage'; import { test, visitStudy, checkForScreenshot, screenShotPaths } from './utils';
import { visitStudy, checkForScreenshot, screenShotPaths } from './utils/index.js';
test.skip('should render TMTV correctly.', async ({ page }) => { test.skip('should render TMTV correctly.', async ({ page }) => {
const studyInstanceUID = '1.2.840.113619.2.290.3.3767434740.226.1600859119.501'; const studyInstanceUID = '1.2.840.113619.2.290.3.3767434740.226.1600859119.501';

View File

@ -1,5 +1,4 @@
import { test } from 'playwright-test-coverage'; import { test, checkForScreenshot, screenShotPaths } from './utils';
import { checkForScreenshot, screenShotPaths } from './utils';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto(`/?datasources=ohif`); await page.goto(`/?datasources=ohif`);

View File

@ -7,11 +7,19 @@ test.beforeEach(async ({ page }) => {
await visitStudy(page, studyInstanceUID, mode, 10000); 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 mainToolbarPageObject.waitForVolumeLoad();
await page.getByTestId('side-panel-header-right').click(); await page.getByTestId('side-panel-header-right').click();
// await page.getByTestId('study-browser-thumbnail-no-image').dblclick(); // 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(() => { await page.evaluate(() => {
// Access cornerstone directly from the window object // 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,
});
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 264 KiB

View File

@ -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<string, unknown> = {};
// Global per-test customization baseline.
export const DEFAULT_E2E_OHIF_CUSTOMIZATIONS: Record<string, unknown> = {
'viewportScrollbar.showViewedFill': false,
'viewportScrollbar.showLoadingPattern': false,
'viewportScrollbar.showLoadedFill': false,
'viewportScrollbar.showLoadedEndpoints': false,
};
export async function addOHIFConfiguration(page: Page, configToAdd: Record<string, unknown>) { export async function addOHIFConfiguration(page: Page, configToAdd: Record<string, unknown>) {
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 => { await page.addInitScript(config => {
let _config; let _config;
Object.defineProperty(window, 'config', { Object.defineProperty(window, 'config', {
@ -15,5 +40,21 @@ export async function addOHIFConfiguration(page: Page, configToAdd: Record<strin
}, },
configurable: true, configurable: true,
}); });
}, configToAdd); }, baselinePlusConfigToAdd);
}
export async function addOHIFGlobalCustomizations(
page: Page,
customizationsToAdd: Record<string, unknown>
) {
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);
} }

View File

@ -1,4 +1,5 @@
import { test as base } from 'playwright-test-coverage'; import { test as base } from 'playwright-test-coverage';
import { addOHIFConfiguration } from './OHIFConfiguration';
import { import {
DOMOverlayPageObject, DOMOverlayPageObject,
MainToolbarPageObject, MainToolbarPageObject,
@ -17,7 +18,18 @@ type PageObjects = {
notFoundStudyPageObject: NotFoundStudyPageObject; notFoundStudyPageObject: NotFoundStudyPageObject;
}; };
export const test = base.extend<PageObjects>({ type TestFixtures = PageObjects & {
_applyGlobalE2EOHIFBaseline: void;
};
export const test = base.extend<TestFixtures>({
_applyGlobalE2EOHIFBaseline: [
async ({ page }, use) => {
await addOHIFConfiguration(page, {});
await use();
},
{ auto: true },
],
DOMOverlayPageObject: async ({ page }, use) => { DOMOverlayPageObject: async ({ page }, use) => {
await use(new DOMOverlayPageObject(page)); await use(new DOMOverlayPageObject(page));
}, },

View File

@ -1,5 +1,8 @@
import { visitStudy } from './visitStudy'; import { visitStudy } from './visitStudy';
import { addOHIFConfiguration } from './OHIFConfiguration'; import {
addOHIFConfiguration,
addOHIFGlobalCustomizations,
} from './OHIFConfiguration';
import { checkForScreenshot } from './checkForScreenshot'; import { checkForScreenshot } from './checkForScreenshot';
import { screenShotPaths } from './screenShotPaths'; import { screenShotPaths } from './screenShotPaths';
import { import {
@ -25,6 +28,7 @@ import { subscribeToMeasurementAdded } from './subscribeToMeasurement';
export { export {
visitStudy, visitStudy,
addOHIFConfiguration, addOHIFConfiguration,
addOHIFGlobalCustomizations,
checkForScreenshot, checkForScreenshot,
screenShotPaths, screenShotPaths,
simulateClicksOnElement, simulateClicksOnElement,