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",
|
||||
"dev": "cross-env NODE_ENV=development webpack serve --config .webpack/webpack.playground.js",
|
||||
"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: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 {
|
||||
SmartScrollbar,
|
||||
useSmartScrollbarLayoutContext,
|
||||
useSmartScrollbarScrollContext,
|
||||
SmartScrollbarTrack,
|
||||
SmartScrollbarFill,
|
||||
SmartScrollbarIndicator,
|
||||
SmartScrollbarEndpoints,
|
||||
useByteArray,
|
||||
} from './SmartScrollbar';
|
||||
import { ThemeWrapper } from './ThemeWrapper';
|
||||
import {
|
||||
Command,
|
||||
@ -269,5 +279,13 @@ export {
|
||||
ProgressLoadingBar,
|
||||
ViewportDialog,
|
||||
CinePlayer,
|
||||
LayoutSelector
|
||||
LayoutSelector,
|
||||
SmartScrollbar,
|
||||
useSmartScrollbarLayoutContext,
|
||||
useSmartScrollbarScrollContext,
|
||||
SmartScrollbarTrack,
|
||||
SmartScrollbarFill,
|
||||
SmartScrollbarIndicator,
|
||||
SmartScrollbarEndpoints,
|
||||
useByteArray,
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user