feat(component): Adds SmartScrollbar to ui-next - OHIF-2558 (#5924)
Co-authored-by: Joe Boccanfuso <joe.boccanfuso@radicalimaging.com>
This commit is contained in:
parent
fdf26aa8b9
commit
91a8715795
10
platform/ui-next/jest.config.js
Normal file
10
platform/ui-next/jest.config.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
const base = require('../../jest.config.base.js');
|
||||||
|
const pkg = require('./package');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...base,
|
||||||
|
displayName: pkg.name,
|
||||||
|
|
||||||
|
// Override the base setting that transforms node_modules.
|
||||||
|
transformIgnorePatterns: ['/node_modules/'],
|
||||||
|
};
|
||||||
@ -17,6 +17,8 @@
|
|||||||
"start": "yarn run build --watch",
|
"start": "yarn run build --watch",
|
||||||
"dev": "cross-env NODE_ENV=development webpack serve --config .webpack/webpack.playground.js",
|
"dev": "cross-env NODE_ENV=development webpack serve --config .webpack/webpack.playground.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"test:unit": "jest --watchAll",
|
||||||
|
"test:unit:ci": "jest --ci --runInBand --collectCoverage",
|
||||||
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
|
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
|
||||||
"build:package": "yarn run build"
|
"build:package": "yarn run build"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -0,0 +1,292 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
Children,
|
||||||
|
isValidElement,
|
||||||
|
} from 'react';
|
||||||
|
import { getIndicatorLayout } from './utils';
|
||||||
|
import { SmartScrollbarIndicator } from './SmartScrollbarIndicator';
|
||||||
|
|
||||||
|
// ── Child validation ────────────────────────────────────────────
|
||||||
|
function validateChildren(children: React.ReactNode): void {
|
||||||
|
let hasIndicator = false;
|
||||||
|
|
||||||
|
Children.forEach(children, child => {
|
||||||
|
if (!isValidElement(child)) return;
|
||||||
|
if (child.type === SmartScrollbarIndicator) hasIndicator = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasIndicator) {
|
||||||
|
throw new Error(
|
||||||
|
'SmartScrollbar: <SmartScrollbarIndicator> is a required child. ' +
|
||||||
|
'Users will not see their current scroll position without it.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout and timing constants ─────────────────────────────────
|
||||||
|
const TRACK_WIDTH = 8;
|
||||||
|
const RESTING_WIDTH = 4;
|
||||||
|
const FILL_PADDING = 3;
|
||||||
|
const INDICATOR_SIZE = 8;
|
||||||
|
const INDICATOR_BORDER_WIDTH = 1;
|
||||||
|
const SETTLE_DELAY = 600;
|
||||||
|
|
||||||
|
// ── Contexts ───────────────────────────────────────────────────
|
||||||
|
export interface SmartScrollbarLayoutContextValue {
|
||||||
|
total: number;
|
||||||
|
trackHeight: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
effectiveWidth: number;
|
||||||
|
trackWidth: number;
|
||||||
|
fillPadding: number;
|
||||||
|
stableLayerEl: HTMLDivElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SmartScrollbarLayoutContext = createContext<SmartScrollbarLayoutContextValue | null>(null);
|
||||||
|
const SmartScrollbarScrollContext = createContext<number | null>(null);
|
||||||
|
|
||||||
|
export function useSmartScrollbarLayoutContext(): SmartScrollbarLayoutContextValue {
|
||||||
|
const ctx = useContext(SmartScrollbarLayoutContext);
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error('SmartScrollbar compound components must be used inside <SmartScrollbar>');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSmartScrollbarScrollContext(): number {
|
||||||
|
const value = useContext(SmartScrollbarScrollContext);
|
||||||
|
if (value === null)
|
||||||
|
throw new Error('SmartScrollbar compound components must be used inside <SmartScrollbar>');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Props ──────────────────────────────────────────────────────
|
||||||
|
interface SmartScrollbarProps {
|
||||||
|
value: number;
|
||||||
|
total: number;
|
||||||
|
onValueChange: (index: number) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
enableKeyboardNavigation?: boolean;
|
||||||
|
'aria-label'?: string;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────
|
||||||
|
export function SmartScrollbar({
|
||||||
|
value,
|
||||||
|
total,
|
||||||
|
onValueChange,
|
||||||
|
isLoading = false,
|
||||||
|
enableKeyboardNavigation = false,
|
||||||
|
'aria-label': ariaLabel = 'Scroll position',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: SmartScrollbarProps) {
|
||||||
|
validateChildren(children);
|
||||||
|
|
||||||
|
// ── ResizeObserver for trackHeight ───────────────────────────
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [trackHeight, setTrackHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(([entry]) => {
|
||||||
|
setTrackHeight(entry.contentRect.height);
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Contraction state ────────────────────────────────────────
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const isDraggingRef = useRef(false);
|
||||||
|
const trackTopRef = useRef(0);
|
||||||
|
|
||||||
|
// Settle delay — only contract after a real loading→done transition
|
||||||
|
const [hasSettled, setHasSettled] = useState(false);
|
||||||
|
const wasEverLoading = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading) {
|
||||||
|
wasEverLoading.current = true;
|
||||||
|
setHasSettled(false);
|
||||||
|
} else if (wasEverLoading.current) {
|
||||||
|
const timer = setTimeout(() => setHasSettled(true), SETTLE_DELAY);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isLoading]);
|
||||||
|
|
||||||
|
const isExpanded = !hasSettled || isHovered || isDragging;
|
||||||
|
const effectiveWidth = isExpanded ? TRACK_WIDTH : RESTING_WIDTH;
|
||||||
|
|
||||||
|
// ── Hit zone extension ───────────────────────────────────────
|
||||||
|
const { leftPos } = getIndicatorLayout(TRACK_WIDTH, INDICATOR_SIZE, INDICATOR_BORDER_WIDTH);
|
||||||
|
const hitZoneLeftExtension = Math.max(0, -leftPos);
|
||||||
|
|
||||||
|
// ── Stable layer (for elements that shouldn't move during contraction) ──
|
||||||
|
// Uses useState + callback ref so React triggers a re-render when the
|
||||||
|
// DOM node mounts — ensuring endpoints render on the first valid pass.
|
||||||
|
const [stableLayerEl, setStableLayerEl] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// ── Pointer helpers ──────────────────────────────────────────
|
||||||
|
const clamp = useCallback(
|
||||||
|
(val: number) => Math.max(0, Math.min(total - 1, val)),
|
||||||
|
[total]
|
||||||
|
);
|
||||||
|
|
||||||
|
const indexFromPointerY = useCallback(
|
||||||
|
(clientY: number) => {
|
||||||
|
const ratio = Math.max(0, Math.min(1, (clientY - trackTopRef.current) / trackHeight));
|
||||||
|
return Math.round(ratio * (total - 1));
|
||||||
|
},
|
||||||
|
[trackHeight, total]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
trackTopRef.current = e.currentTarget.getBoundingClientRect().top;
|
||||||
|
|
||||||
|
isDraggingRef.current = true;
|
||||||
|
setIsDragging(true);
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
|
||||||
|
onValueChange(clamp(indexFromPointerY(e.clientY)));
|
||||||
|
},
|
||||||
|
[clamp, indexFromPointerY, onValueChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (!isDraggingRef.current) return;
|
||||||
|
onValueChange(clamp(indexFromPointerY(e.clientY)));
|
||||||
|
},
|
||||||
|
[clamp, indexFromPointerY, onValueChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
||||||
|
isDraggingRef.current = false;
|
||||||
|
setIsDragging(false);
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Keyboard interaction (WAI-ARIA slider spec) ────────────
|
||||||
|
const PAGE_STEP = 10;
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
let next: number | null = null;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowUp':
|
||||||
|
case 'ArrowLeft':
|
||||||
|
next = value - 1;
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
case 'ArrowRight':
|
||||||
|
next = value + 1;
|
||||||
|
break;
|
||||||
|
case 'PageUp':
|
||||||
|
next = value - PAGE_STEP;
|
||||||
|
break;
|
||||||
|
case 'PageDown':
|
||||||
|
next = value + PAGE_STEP;
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
next = 0;
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
next = total - 1;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
onValueChange(clamp(next));
|
||||||
|
},
|
||||||
|
[value, total, clamp, onValueChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Context values ───────────────────────────────────────────
|
||||||
|
const layoutCtx = useMemo<SmartScrollbarLayoutContextValue>(() => ({
|
||||||
|
total,
|
||||||
|
trackHeight,
|
||||||
|
isLoading,
|
||||||
|
effectiveWidth,
|
||||||
|
trackWidth: TRACK_WIDTH,
|
||||||
|
fillPadding: FILL_PADDING,
|
||||||
|
stableLayerEl,
|
||||||
|
}), [total, trackHeight, isLoading, effectiveWidth, stableLayerEl]);
|
||||||
|
return (
|
||||||
|
<SmartScrollbarLayoutContext.Provider value={layoutCtx}>
|
||||||
|
<SmartScrollbarScrollContext.Provider value={value}>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
role="slider"
|
||||||
|
aria-valuenow={value}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={total - 1}
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
tabIndex={0}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
width: TRACK_WIDTH + hitZoneLeftExtension,
|
||||||
|
height: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
marginLeft: -hitZoneLeftExtension,
|
||||||
|
cursor: isDragging ? 'grabbing' : 'grab',
|
||||||
|
touchAction: 'none',
|
||||||
|
}}
|
||||||
|
onPointerEnter={() => setIsHovered(true)}
|
||||||
|
onPointerLeave={() => setIsHovered(false)}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
onKeyDown={enableKeyboardNavigation ? handleKeyDown : undefined}
|
||||||
|
>
|
||||||
|
{trackHeight > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
width: TRACK_WIDTH,
|
||||||
|
height: trackHeight,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{
|
||||||
|
width: effectiveWidth,
|
||||||
|
height: trackHeight,
|
||||||
|
transition: 'width 300ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{/* Stable layer — always TRACK_WIDTH, never contracts. For elements like
|
||||||
|
endpoints that must not jitter during width transitions. Children
|
||||||
|
render here via createPortal using stableLayerRef from context. */}
|
||||||
|
<div
|
||||||
|
ref={setStableLayerEl}
|
||||||
|
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SmartScrollbarScrollContext.Provider>
|
||||||
|
</SmartScrollbarLayoutContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useSmartScrollbarLayoutContext } from './SmartScrollbar';
|
||||||
|
import { computePixelFilledFromMarked } from './utils';
|
||||||
|
|
||||||
|
// ── Endpoint cap dimensions and color ───────────────────────────
|
||||||
|
const CAP_SIZE = 4;
|
||||||
|
const CAP_HEIGHT = CAP_SIZE / 2 + 1; // 3
|
||||||
|
const CAP_COLOR = 'hsl(var(--neutral) / 1.0)';
|
||||||
|
|
||||||
|
interface SmartScrollbarEndpointsProps {
|
||||||
|
marked: Uint8Array;
|
||||||
|
/**
|
||||||
|
* Change token that MUST be bumped when the contents of `marked` change while
|
||||||
|
* the `marked` array reference stays the same (in-place mutation).
|
||||||
|
*
|
||||||
|
* Recommended: manage `marked` + `version` together via `useByteArray()`.
|
||||||
|
*/
|
||||||
|
version: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmartScrollbarEndpoints = React.memo(function SmartScrollbarEndpoints({
|
||||||
|
marked,
|
||||||
|
// `marked` is mutated in-place (stable reference). We accept `version` only to
|
||||||
|
// invalidate React.memo and force a re-render when the bytes change. The
|
||||||
|
// leading underscore indicates the value is intentionally unused in this component.
|
||||||
|
version: _version,
|
||||||
|
className,
|
||||||
|
}: SmartScrollbarEndpointsProps) {
|
||||||
|
const { trackHeight, trackWidth, fillPadding, stableLayerEl } =
|
||||||
|
useSmartScrollbarLayoutContext();
|
||||||
|
|
||||||
|
const fillAreaTop = fillPadding;
|
||||||
|
const pixelCount = Math.max(0, Math.floor(trackHeight - fillPadding * 2));
|
||||||
|
if (pixelCount === 0) return null;
|
||||||
|
const pixelFilled = computePixelFilledFromMarked(marked, pixelCount);
|
||||||
|
|
||||||
|
// Scan for the first and last filled pixel row in O(n) so endpoints align
|
||||||
|
// exactly with the fill rendering in pixel space.
|
||||||
|
let firstFilledPixel = -1;
|
||||||
|
let lastFilledPixel = -1;
|
||||||
|
for (let pixel = 0; pixel < pixelFilled.length; pixel++) {
|
||||||
|
if (pixelFilled[pixel]) {
|
||||||
|
firstFilledPixel = pixel;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let pixel = pixelFilled.length - 1; pixel >= 0; pixel--) {
|
||||||
|
if (pixelFilled[pixel]) {
|
||||||
|
lastFilledPixel = pixel;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstFilledPixel === -1 || trackHeight === 0 || !stableLayerEl) return null;
|
||||||
|
|
||||||
|
// Use trackWidth (always 8px) not effectiveWidth — endpoints must stay
|
||||||
|
// stationary during contraction/expansion transitions.
|
||||||
|
const cx = trackWidth / 2;
|
||||||
|
const halfCap = CAP_SIZE / 2;
|
||||||
|
const topEdge = fillAreaTop + firstFilledPixel;
|
||||||
|
const bottomEdge = fillAreaTop + (lastFilledPixel + 1);
|
||||||
|
|
||||||
|
// Portal into the stable layer so position isn't affected by the
|
||||||
|
// contracting track div's width transition.
|
||||||
|
return createPortal(
|
||||||
|
<svg
|
||||||
|
width={trackWidth}
|
||||||
|
height={trackHeight}
|
||||||
|
className={`pointer-events-none absolute inset-0 ${className ?? ''}`}
|
||||||
|
>
|
||||||
|
{/* Top cap */}
|
||||||
|
<rect
|
||||||
|
x={cx - halfCap}
|
||||||
|
y={topEdge - CAP_HEIGHT}
|
||||||
|
width={CAP_SIZE}
|
||||||
|
height={CAP_HEIGHT}
|
||||||
|
fill={CAP_COLOR}
|
||||||
|
/>
|
||||||
|
{/* Bottom cap */}
|
||||||
|
<rect
|
||||||
|
x={cx - halfCap}
|
||||||
|
y={bottomEdge}
|
||||||
|
width={CAP_SIZE}
|
||||||
|
height={CAP_HEIGHT}
|
||||||
|
fill={CAP_COLOR}
|
||||||
|
/>
|
||||||
|
</svg>,
|
||||||
|
stableLayerEl
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useSmartScrollbarLayoutContext } from './SmartScrollbar';
|
||||||
|
import { computeContiguousRuns, computePixelFilledFromMarked } from './utils';
|
||||||
|
|
||||||
|
interface SmartScrollbarFillProps {
|
||||||
|
marked: Uint8Array;
|
||||||
|
/**
|
||||||
|
* Change token that MUST be bumped when the contents of `marked` change while
|
||||||
|
* the `marked` array reference stays the same (in-place mutation).
|
||||||
|
*
|
||||||
|
* Recommended: manage `marked` + `version` together via `useByteArray()`.
|
||||||
|
*/
|
||||||
|
version: number;
|
||||||
|
className?: string;
|
||||||
|
loadingClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmartScrollbarFill = React.memo(function SmartScrollbarFill({
|
||||||
|
marked,
|
||||||
|
version,
|
||||||
|
className,
|
||||||
|
loadingClassName,
|
||||||
|
}: SmartScrollbarFillProps) {
|
||||||
|
const { trackHeight, effectiveWidth, fillPadding, isLoading } = useSmartScrollbarLayoutContext();
|
||||||
|
|
||||||
|
const runs = useMemo(() => {
|
||||||
|
// Render fill in pixel space so the fill never overstates coverage when
|
||||||
|
// many indices map into a single pixel row (subpixel heights).
|
||||||
|
const pixelCount = Math.max(0, Math.floor(trackHeight - fillPadding * 2));
|
||||||
|
const pixelFilled = computePixelFilledFromMarked(marked, pixelCount);
|
||||||
|
return computeContiguousRuns(pixelFilled);
|
||||||
|
}, [marked, version, trackHeight, fillPadding]);
|
||||||
|
|
||||||
|
if (runs.length === 0 || trackHeight === 0) return null;
|
||||||
|
|
||||||
|
const fillAreaTop = fillPadding;
|
||||||
|
const activeClass = isLoading && loadingClassName ? loadingClassName : className;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{runs.map((run, index) => {
|
||||||
|
const top = fillAreaTop + run.start;
|
||||||
|
const height = run.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`absolute ${activeClass ?? ''}`}
|
||||||
|
style={{
|
||||||
|
left: 0,
|
||||||
|
top,
|
||||||
|
width: effectiveWidth,
|
||||||
|
height,
|
||||||
|
transition: 'width 300ms ease, left 300ms ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSmartScrollbarLayoutContext, useSmartScrollbarScrollContext } from './SmartScrollbar';
|
||||||
|
import { getIndicatorLayout } 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 {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SmartScrollbarIndicator({ className }: SmartScrollbarIndicatorProps) {
|
||||||
|
const { total, trackHeight, effectiveWidth, fillPadding } =
|
||||||
|
useSmartScrollbarLayoutContext();
|
||||||
|
const value = useSmartScrollbarScrollContext();
|
||||||
|
|
||||||
|
if (trackHeight === 0 || total <= 1) return null;
|
||||||
|
|
||||||
|
const { totalWidth, totalHeight, fillWidth, fillHeight, leftPos } = getIndicatorLayout(
|
||||||
|
effectiveWidth,
|
||||||
|
INDICATOR_SIZE,
|
||||||
|
BORDER_WIDTH
|
||||||
|
);
|
||||||
|
|
||||||
|
const offsetY = (totalHeight - INDICATOR_SIZE) / 2;
|
||||||
|
const fillAreaTop = fillPadding;
|
||||||
|
const pixelCount = Math.max(0, Math.floor(trackHeight - fillPadding * 2));
|
||||||
|
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 itemStartPx = Math.floor((clampedValue * pixelCount) / total);
|
||||||
|
const maxTopInFill = Math.max(0, pixelCount - INDICATOR_SIZE);
|
||||||
|
const topInFill = Math.max(0, Math.min(maxTopInFill, itemStartPx));
|
||||||
|
const y = fillAreaTop + topInFill;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none absolute ${className ?? ''}`}
|
||||||
|
style={{
|
||||||
|
left: leftPos,
|
||||||
|
top: y - offsetY,
|
||||||
|
width: totalWidth,
|
||||||
|
height: totalHeight,
|
||||||
|
transition: 'left 300ms ease, opacity 300ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
import React, { useId, useState, useEffect } from 'react';
|
||||||
|
import { useSmartScrollbarLayoutContext } from './SmartScrollbar';
|
||||||
|
|
||||||
|
// ── Dot-grid pattern constants ──────────────────────────────────
|
||||||
|
const DOT_SIZE = 2;
|
||||||
|
const DOT_GAP = 4;
|
||||||
|
const DOT_STEP = DOT_SIZE + DOT_GAP; // 6px
|
||||||
|
const DOT_RADIUS = DOT_SIZE / 2;
|
||||||
|
const FADE_DURATION_MS = 500;
|
||||||
|
|
||||||
|
interface SmartScrollbarTrackProps {
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DotGrid({ w, h, patternId }: { w: number; h: number; patternId: string }) {
|
||||||
|
const dotColor = `hsl(var(--neutral) / 0.5)`;
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={w}
|
||||||
|
height={h}
|
||||||
|
className="absolute inset-0"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<pattern
|
||||||
|
id={patternId}
|
||||||
|
width={DOT_STEP}
|
||||||
|
height={DOT_STEP}
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx={DOT_RADIUS}
|
||||||
|
cy={DOT_RADIUS}
|
||||||
|
r={DOT_RADIUS}
|
||||||
|
fill={dotColor}
|
||||||
|
/>
|
||||||
|
</pattern>
|
||||||
|
<clipPath id={`${patternId}-clip`}>
|
||||||
|
<rect
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
width={w}
|
||||||
|
height={h}
|
||||||
|
/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
width={w}
|
||||||
|
height={h}
|
||||||
|
fill={`url(#${patternId})`}
|
||||||
|
clipPath={`url(#${patternId}-clip)`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmartScrollbarTrack = React.memo(function SmartScrollbarTrack({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: SmartScrollbarTrackProps) {
|
||||||
|
const { trackHeight, effectiveWidth, isLoading } = useSmartScrollbarLayoutContext();
|
||||||
|
const patternId = useId();
|
||||||
|
|
||||||
|
// Keep the dot grid mounted long enough to fade out, then unmount entirely.
|
||||||
|
const [dotGridMounted, setDotGridMounted] = useState(isLoading);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading) {
|
||||||
|
setDotGridMounted(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = setTimeout(() => setDotGridMounted(false), FADE_DURATION_MS);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [isLoading]);
|
||||||
|
|
||||||
|
if (trackHeight === 0) return null;
|
||||||
|
|
||||||
|
const w = effectiveWidth;
|
||||||
|
const h = trackHeight;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`absolute inset-0 ${className ?? ''}`}>
|
||||||
|
{dotGridMounted && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
opacity: isLoading ? 1 : 0,
|
||||||
|
transition: `opacity ${FADE_DURATION_MS}ms ease`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DotGrid
|
||||||
|
w={w}
|
||||||
|
h={h}
|
||||||
|
patternId={patternId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
8
platform/ui-next/src/components/SmartScrollbar/index.ts
Normal file
8
platform/ui-next/src/components/SmartScrollbar/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export { SmartScrollbar, useSmartScrollbarLayoutContext, useSmartScrollbarScrollContext } from './SmartScrollbar';
|
||||||
|
export type { SmartScrollbarLayoutContextValue } from './SmartScrollbar';
|
||||||
|
export { SmartScrollbarTrack } from './SmartScrollbarTrack';
|
||||||
|
export { SmartScrollbarFill } from './SmartScrollbarFill';
|
||||||
|
export { SmartScrollbarIndicator } from './SmartScrollbarIndicator';
|
||||||
|
export { SmartScrollbarEndpoints } from './SmartScrollbarEndpoints';
|
||||||
|
export { useByteArray } from './useByteArray';
|
||||||
|
export type { ByteArrayHandle } from './useByteArray';
|
||||||
106
platform/ui-next/src/components/SmartScrollbar/useByteArray.ts
Normal file
106
platform/ui-next/src/components/SmartScrollbar/useByteArray.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import debounce from 'lodash.debounce';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export interface ByteArrayHandle {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
version: number;
|
||||||
|
/** True when every byte in the array is set (all positions marked). */
|
||||||
|
isFull: boolean;
|
||||||
|
setByte: (index: number) => void;
|
||||||
|
clearByte: (index: number) => void;
|
||||||
|
resetWith: (populate: (bytes: Uint8Array) => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages a mutable Uint8Array (one byte per position) with React change
|
||||||
|
* detection via an incrementing version counter.
|
||||||
|
*
|
||||||
|
* @param size - Number of positions (e.g. total slices in a viewport).
|
||||||
|
* @param debounceMs - When > 0, version bumps are debounced by this many
|
||||||
|
* milliseconds. Byte writes are always immediate. Use for
|
||||||
|
* high-frequency sources (cache prefetch) to batch renders.
|
||||||
|
* Omit or pass 0 for immediate re-renders (e.g. viewed tracking).
|
||||||
|
*/
|
||||||
|
export function useByteArray(size: number, debounceMs = 0): ByteArrayHandle {
|
||||||
|
const bytesRef = useRef(new Uint8Array(size));
|
||||||
|
const countRef = useRef(0);
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
|
||||||
|
// Debounced bump — recreated when debounceMs changes; cancelled on unmount
|
||||||
|
// or when debounceMs changes, following the lodash.debounce pattern used
|
||||||
|
// throughout ui-next (InputFilter, CinePlayer).
|
||||||
|
const debouncedBump = useMemo(
|
||||||
|
() => (debounceMs > 0 ? debounce(() => setVersion(v => v + 1), debounceMs) : null),
|
||||||
|
[debounceMs]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => debouncedBump?.cancel();
|
||||||
|
}, [debouncedBump]);
|
||||||
|
|
||||||
|
// Reset array only when size actually changes — skip on initial mount since
|
||||||
|
// bytesRef is already initialised to the correct size via useRef.
|
||||||
|
useEffect(() => {
|
||||||
|
if (bytesRef.current.length === size) return;
|
||||||
|
debouncedBump?.cancel();
|
||||||
|
bytesRef.current = new Uint8Array(size);
|
||||||
|
countRef.current = 0;
|
||||||
|
setVersion(v => v + 1);
|
||||||
|
}, [size, debouncedBump]);
|
||||||
|
|
||||||
|
const bump = useCallback(() => {
|
||||||
|
if (debouncedBump) {
|
||||||
|
debouncedBump();
|
||||||
|
} else {
|
||||||
|
setVersion(v => v + 1);
|
||||||
|
}
|
||||||
|
}, [debouncedBump]);
|
||||||
|
|
||||||
|
const setByte = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const bytes = bytesRef.current;
|
||||||
|
if (index < 0 || index >= bytes.length || bytes[index] === 1) return;
|
||||||
|
bytes[index] = 1;
|
||||||
|
countRef.current++;
|
||||||
|
bump();
|
||||||
|
},
|
||||||
|
[bump]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearByte = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const bytes = bytesRef.current;
|
||||||
|
if (index < 0 || index >= bytes.length || bytes[index] === 0) return;
|
||||||
|
bytes[index] = 0;
|
||||||
|
countRef.current--;
|
||||||
|
bump();
|
||||||
|
},
|
||||||
|
[bump]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetWith = useCallback(
|
||||||
|
(populate: (bytes: Uint8Array) => void) => {
|
||||||
|
const bytes = bytesRef.current;
|
||||||
|
bytes.fill(0);
|
||||||
|
populate(bytes);
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
if (bytes[i]) count++;
|
||||||
|
}
|
||||||
|
countRef.current = count;
|
||||||
|
bump();
|
||||||
|
},
|
||||||
|
[bump]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bytes: bytesRef.current,
|
||||||
|
version,
|
||||||
|
// countRef.current is read at render time (triggered by version bump) so
|
||||||
|
// it is always up to date when this value is consumed.
|
||||||
|
isFull: size > 0 && countRef.current === size,
|
||||||
|
setByte,
|
||||||
|
clearByte,
|
||||||
|
resetWith,
|
||||||
|
};
|
||||||
|
}
|
||||||
83
platform/ui-next/src/components/SmartScrollbar/utils.test.ts
Normal file
83
platform/ui-next/src/components/SmartScrollbar/utils.test.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { computeContiguousRuns, computePixelFilledFromMarked } from './utils';
|
||||||
|
|
||||||
|
function u8(values: number[]): Uint8Array {
|
||||||
|
return new Uint8Array(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computePixelFilledFromMarked', () => {
|
||||||
|
it('returns empty when pixelCount <= 0', () => {
|
||||||
|
expect(computePixelFilledFromMarked(u8([1, 1, 1]), 0)).toEqual(new Uint8Array(0));
|
||||||
|
expect(computePixelFilledFromMarked(u8([1, 1, 1]), -10)).toEqual(new Uint8Array(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty when marked is empty', () => {
|
||||||
|
expect(computePixelFilledFromMarked(new Uint8Array(0), 10)).toEqual(new Uint8Array(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downsamples conservatively when total >= pixelCount (AND within each bucket)', () => {
|
||||||
|
// total=7, pixelCount=3 => buckets [0..2), [2..4), [4..7) (requires rounding)
|
||||||
|
const marked = u8([1, 1, 1, 0, 1, 1, 1]);
|
||||||
|
const result = computePixelFilledFromMarked(marked, 3);
|
||||||
|
expect(Array.from(result)).toEqual([1, 0, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downsamples conservatively with uneven bucket sizes (requires rounding)', () => {
|
||||||
|
// total=5, pixelCount=4 => buckets [0..1), [1..2), [2..3), [3..5)
|
||||||
|
const marked = u8([1, 1, 1, 1, 0]);
|
||||||
|
const result = computePixelFilledFromMarked(marked, 4);
|
||||||
|
expect(Array.from(result)).toEqual([1, 1, 1, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upsamples by filling the full pixel span of each marked item when pixelCount > total', () => {
|
||||||
|
// total=4, pixelCount=7 =>
|
||||||
|
// item 0 spans pixels [0..1) (requires rounding)
|
||||||
|
// item 2 spans pixels [3..5) (requires rounding)
|
||||||
|
const marked = u8([1, 0, 1, 0]);
|
||||||
|
const result = computePixelFilledFromMarked(marked, 7);
|
||||||
|
expect(Array.from(result)).toEqual([1, 0, 0, 1, 1, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upsamples correctly when a marked item maps to a single pixel row', () => {
|
||||||
|
// total=6, pixelCount=7 => items 0..4 each map to exactly 1 pixel; item 5 maps to 2 pixels.
|
||||||
|
const marked = u8([0, 0, 0, 1, 0, 1]); // item 3 => pixel 3; item 5 => pixels 5-6
|
||||||
|
const result = computePixelFilledFromMarked(marked, 7);
|
||||||
|
expect(Array.from(result)).toEqual([0, 0, 0, 1, 0, 1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses Math.floor(pixelCount) for output length (downsample branch)', () => {
|
||||||
|
const marked = u8([1, 0, 1, 0]);
|
||||||
|
expect(computePixelFilledFromMarked(marked, 3.9)).toHaveLength(3);
|
||||||
|
expect(computePixelFilledFromMarked(marked, 3.1)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses Math.floor(pixelCount) for output length (upsample branch)', () => {
|
||||||
|
const marked = u8([1, 0, 1]);
|
||||||
|
expect(computePixelFilledFromMarked(marked, 7.9)).toHaveLength(7);
|
||||||
|
expect(computePixelFilledFromMarked(marked, 7.1)).toHaveLength(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeContiguousRuns', () => {
|
||||||
|
it('returns [] for empty input', () => {
|
||||||
|
expect(computeContiguousRuns(new Uint8Array(0))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] when all bytes are zero', () => {
|
||||||
|
expect(computeContiguousRuns(u8([0, 0, 0, 0]))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a single run when all bytes are non-zero', () => {
|
||||||
|
expect(computeContiguousRuns(u8([1, 1, 2, 3]))).toEqual([{ start: 0, length: 4 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds runs with leading/trailing zeros', () => {
|
||||||
|
expect(computeContiguousRuns(u8([0, 0, 1, 1, 0, 2, 0, 0]))).toEqual([
|
||||||
|
{ start: 2, length: 2 },
|
||||||
|
{ start: 5, length: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats any non-zero byte as filled', () => {
|
||||||
|
expect(computeContiguousRuns(u8([0, 255, 128, 0]))).toEqual([{ start: 1, length: 2 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
97
platform/ui-next/src/components/SmartScrollbar/utils.ts
Normal file
97
platform/ui-next/src/components/SmartScrollbar/utils.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
export interface ContiguousRun {
|
||||||
|
start: number;
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a Uint8Array where each non-zero byte represents a set position,
|
||||||
|
* returns contiguous runs in a single O(n) pass. No sorting or heap
|
||||||
|
* allocations inside the loop.
|
||||||
|
*/
|
||||||
|
export function computeContiguousRuns(bytes: Uint8Array): ContiguousRun[] {
|
||||||
|
const runs: ContiguousRun[] = [];
|
||||||
|
const n = bytes.length;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
while (i < n && bytes[i] === 0) i++;
|
||||||
|
if (i >= n) break;
|
||||||
|
|
||||||
|
const start = i;
|
||||||
|
while (i < n && bytes[i] !== 0) i++;
|
||||||
|
|
||||||
|
runs.push({ start, length: i - start });
|
||||||
|
}
|
||||||
|
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* its mapped items are all marked, so the fill never overstates coverage.
|
||||||
|
*
|
||||||
|
* - If `total >= pixelCount`: each pixel row maps to a disjoint item-index
|
||||||
|
* range; a pixel row is filled only if all items in that range are marked.
|
||||||
|
* - If `pixelCount > total`: each item spans multiple pixel rows; if the item
|
||||||
|
* is marked, its entire pixel span is filled.
|
||||||
|
*/
|
||||||
|
export function computePixelFilledFromMarked(
|
||||||
|
marked: Uint8Array,
|
||||||
|
pixelCount: number
|
||||||
|
): Uint8Array {
|
||||||
|
const total = marked.length;
|
||||||
|
const count = Math.max(0, Math.floor(pixelCount));
|
||||||
|
if (count === 0 || total <= 0) return new Uint8Array(0);
|
||||||
|
|
||||||
|
const pixelFilled = new Uint8Array(count);
|
||||||
|
|
||||||
|
if (total >= count) {
|
||||||
|
for (let pixelIndex = 0; pixelIndex < count; pixelIndex++) {
|
||||||
|
const start = Math.floor((pixelIndex * total) / count);
|
||||||
|
const end = Math.floor(((pixelIndex + 1) * total) / count);
|
||||||
|
if (end <= start) continue;
|
||||||
|
|
||||||
|
let filled = 1;
|
||||||
|
for (let itemIndex = start; itemIndex < end; itemIndex++) {
|
||||||
|
if (marked[itemIndex] === 0) {
|
||||||
|
filled = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pixelFilled[pixelIndex] = filled;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let itemIndex = 0; itemIndex < total; itemIndex++) {
|
||||||
|
if (marked[itemIndex] === 0) continue;
|
||||||
|
const topPx = Math.floor((itemIndex * count) / total);
|
||||||
|
const bottomPx = Math.floor(((itemIndex + 1) * count) / total);
|
||||||
|
for (let pixel = topPx; pixel < bottomPx; pixel++) {
|
||||||
|
pixelFilled[pixel] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pixelFilled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the indicator's total visual dimensions and horizontal position.
|
||||||
|
* Design 27: pill shape, center position, 1px border.
|
||||||
|
*/
|
||||||
|
export function getIndicatorLayout(
|
||||||
|
trackWidth: number,
|
||||||
|
indicatorSize: number,
|
||||||
|
borderWidth: number,
|
||||||
|
): { totalWidth: number; totalHeight: number; fillWidth: number; fillHeight: number; leftPos: number } {
|
||||||
|
const visualSize = indicatorSize * 1.25;
|
||||||
|
const fillWidth = visualSize;
|
||||||
|
const fillHeight = Math.round(visualSize / 2); // pill = half height
|
||||||
|
const totalWidth = fillWidth + borderWidth * 2;
|
||||||
|
const totalHeight = fillHeight + borderWidth * 2;
|
||||||
|
|
||||||
|
const centerX = trackWidth / 2;
|
||||||
|
const leftPos = centerX - totalWidth / 2;
|
||||||
|
|
||||||
|
return { totalWidth, totalHeight, fillWidth, fillHeight, leftPos };
|
||||||
|
}
|
||||||
@ -1,4 +1,14 @@
|
|||||||
import { Button, buttonVariants } from './Button';
|
import { Button, buttonVariants } from './Button';
|
||||||
|
import {
|
||||||
|
SmartScrollbar,
|
||||||
|
useSmartScrollbarLayoutContext,
|
||||||
|
useSmartScrollbarScrollContext,
|
||||||
|
SmartScrollbarTrack,
|
||||||
|
SmartScrollbarFill,
|
||||||
|
SmartScrollbarIndicator,
|
||||||
|
SmartScrollbarEndpoints,
|
||||||
|
useByteArray,
|
||||||
|
} from './SmartScrollbar';
|
||||||
import { ThemeWrapper } from './ThemeWrapper';
|
import { ThemeWrapper } from './ThemeWrapper';
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@ -269,5 +279,13 @@ export {
|
|||||||
ProgressLoadingBar,
|
ProgressLoadingBar,
|
||||||
ViewportDialog,
|
ViewportDialog,
|
||||||
CinePlayer,
|
CinePlayer,
|
||||||
LayoutSelector
|
LayoutSelector,
|
||||||
|
SmartScrollbar,
|
||||||
|
useSmartScrollbarLayoutContext,
|
||||||
|
useSmartScrollbarScrollContext,
|
||||||
|
SmartScrollbarTrack,
|
||||||
|
SmartScrollbarFill,
|
||||||
|
SmartScrollbarIndicator,
|
||||||
|
SmartScrollbarEndpoints,
|
||||||
|
useByteArray,
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user