feat(slice scrollbar): Integrate ViewportSliceProgressScrollbar with customizations and docs (#5960)
Co-authored-by: Dan Rukas <dan.rukas@gmail.com>
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default } from './ViewportSliceProgressScrollbar';
|
||||||
@ -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;
|
||||||
|
};
|
||||||
@ -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': {},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
import ViewedDataService from './ViewedDataService';
|
||||||
|
|
||||||
|
export default ViewedDataService;
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 85 KiB |
@ -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 <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)}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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.
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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 item’s 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 />,
|
||||||
|
};
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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([]);
|
||||||
|
|||||||
@ -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 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(
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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`);
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 264 KiB |
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||