feat(slice scrollbar): Integrate ViewportSliceProgressScrollbar with customizations and docs (#5960)
Co-authored-by: Dan Rukas <dan.rukas@gmail.com>
This commit is contained in:
1 parent
8a2fee4d8a
commit
8fc0dc16e9
46 files changed
+1730
-279
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
@@ -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)}
|
||||
+340
-70
@@ -15,6 +15,12 @@ import progressLoading from '../../../assets/img/Loading-Indicator.png';
|
||||
import loadingIndicatorProgress from '../../../assets/img/loading-indicator-icon.png';
|
||||
import loadingIndicatorPercent from '../../../assets/img/loading-indicator-percent.png';
|
||||
import viewportActionCorners from '../../../assets/img/viewport-action-corners.png';
|
||||
import viewportScrollbarVariantProgress from '../../../assets/img/viewport-scrollbar-variant-progress.png';
|
||||
import viewportScrollbarVariantLegacy from '../../../assets/img/viewport-scrollbar-variant-legacy.png';
|
||||
import viewportScrollbarShowLoadedEndpoints from '../../../assets/img/viewport-scrollbar-showLoadedEndpoints.png';
|
||||
import viewportScrollbarShowLoadedFill from '../../../assets/img/viewport-scrollbar-showLoadedFill.png';
|
||||
import viewportScrollbarShowViewedFill from '../../../assets/img/viewport-scrollbar-showViewedFill.png';
|
||||
import viewportScrollbarShowLoadingPattern from '../../../assets/img/viewport-scrollbar-showLoadingPattern.png';
|
||||
import contextMenu from '../../../assets/img/context-menu.jpg';
|
||||
import viewportDownloadWarning from '../../../assets/img/viewport-download-warning.png';
|
||||
import segmentationOverlay from '../../../assets/img/segmentation-overlay.png';
|
||||
@@ -159,6 +165,197 @@ window.config = {
|
||||
},
|
||||
];
|
||||
|
||||
export const viewportScrollbarCustomizations = [
|
||||
{
|
||||
id: 'viewportScrollbar.variant',
|
||||
description: (
|
||||
<>
|
||||
Controls which scrollbar implementation is rendered. Use <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 = [
|
||||
{
|
||||
id: 'ohif.hotkeyBindings',
|
||||
@@ -1895,76 +2092,149 @@ window.config = {
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeCustomizationImageItem(item: unknown): {
|
||||
img: unknown;
|
||||
caption?: React.ReactNode;
|
||||
} {
|
||||
if (
|
||||
item !== null &&
|
||||
typeof item === 'object' &&
|
||||
'img' in item &&
|
||||
typeof (item as { img: unknown }).img !== 'undefined'
|
||||
) {
|
||||
const o = item as { img: unknown; caption?: React.ReactNode };
|
||||
return { img: o.img, caption: o.caption };
|
||||
}
|
||||
return { img: item };
|
||||
}
|
||||
|
||||
export const TableGenerator = (customizations: any[]) => {
|
||||
return customizations.map(({ id, description, default: defaultValue, configuration, image }) => (
|
||||
<div
|
||||
key={id}
|
||||
style={{ marginBottom: '2rem', borderRadius: '8px', padding: '1rem' }}
|
||||
>
|
||||
<h3
|
||||
id={id.toLowerCase().replace(/\./g, '')}
|
||||
style={{ marginBottom: '1rem', fontSize: '1.5rem' }}
|
||||
return customizations.map(
|
||||
({ id, description, default: defaultValue, configuration, configurationIntro, image }) => (
|
||||
<div
|
||||
key={id}
|
||||
style={{ marginBottom: '2rem', borderRadius: '8px', padding: '1rem' }}
|
||||
>
|
||||
{id}
|
||||
</h3>
|
||||
<table style={{ width: '100%', tableLayout: 'fixed' }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', verticalAlign: 'top', width: '20%' }}>ID</th>
|
||||
<td style={{ wordBreak: 'break-word' }}>
|
||||
<code>{id}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', verticalAlign: 'top', width: '20%' }}>Description</th>
|
||||
<td>
|
||||
<div>{description}</div>
|
||||
{image && (
|
||||
<div>
|
||||
{Array.isArray(image) ? (
|
||||
image.map((img, index) => (
|
||||
<Image
|
||||
key={index}
|
||||
img={img}
|
||||
alt={`${id}-${index + 1}`}
|
||||
style={{ width: '400px' }}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Image
|
||||
img={image}
|
||||
alt={id}
|
||||
style={{ width: '400px' }}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
<pre>
|
||||
<code>{configuration}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
));
|
||||
<h3
|
||||
id={id.toLowerCase().replace(/\./g, '')}
|
||||
style={{ marginBottom: '1rem', fontSize: '1.5rem' }}
|
||||
>
|
||||
{id}
|
||||
</h3>
|
||||
<table style={{ width: '100%', tableLayout: 'fixed' }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', verticalAlign: 'top', width: '20%' }}>ID</th>
|
||||
<td style={{ wordBreak: 'break-word' }}>
|
||||
<code>{id}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', verticalAlign: 'top', width: '20%' }}>Description</th>
|
||||
<td>
|
||||
<div>{description}</div>
|
||||
{image && (
|
||||
<div>
|
||||
{Array.isArray(image)
|
||||
? image.map((item, index) => {
|
||||
const { img, caption } = normalizeCustomizationImageItem(item);
|
||||
const altText =
|
||||
typeof caption === 'string' && caption.length > 0
|
||||
? `${id}: ${caption}`
|
||||
: `${id} screenshot ${index + 1}`;
|
||||
return (
|
||||
<figure
|
||||
key={index}
|
||||
style={{ margin: '0 0 1rem 0' }}
|
||||
>
|
||||
<Image
|
||||
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>
|
||||
);
|
||||
})
|
||||
: (() => {
|
||||
const { img, caption } = normalizeCustomizationImageItem(image);
|
||||
const altText =
|
||||
typeof caption === 'string' && caption.length > 0
|
||||
? `${id}: ${caption}`
|
||||
: id;
|
||||
return (
|
||||
<figure style={{ margin: 0 }}>
|
||||
<Image
|
||||
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)
|
||||
- [Customization Service](./../customization-service/customizationService.md)
|
||||
- [Panel Service](../data/PanelService.md)
|
||||
- [Viewed Data Service](../data/ViewedDataService.md)
|
||||
|
||||
## Service Architecture
|
||||
|
||||
|
||||
@@ -105,6 +105,17 @@ The following services is available in the `OHIF-v3`.
|
||||
ToolBarService
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="./data/ViewedDataService">
|
||||
ViewedDataService
|
||||
</a>
|
||||
</td>
|
||||
<td>Data Service</td>
|
||||
<td>
|
||||
viewedDataService
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="./ui/viewport-grid-service">
|
||||
|
||||
Reference in new issue
Block a user