feat(component): Adds SmartScrollbar to ui-next - OHIF-2558 (#5924)

Co-authored-by: Joe Boccanfuso <joe.boccanfuso@radicalimaging.com>
This commit is contained in:
Dan Rukas 2026-04-06 09:21:13 -04:00 committed by GitHub
parent fdf26aa8b9
commit 91a8715795
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 953 additions and 1 deletions

View 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/'],
};

View File

@ -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"
},

View File

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

View File

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

View File

@ -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',
}}
/>
);
})}
</>
);
});

View File

@ -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 items pixel bucket(s) so it sits “over” the
// same pixel-space mapping used by fill/endpoints.
const clampedValue = Math.max(0, Math.min(total - 1, value));
const 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>
);
}

View File

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

View 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';

View 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,
};
}

View 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 }]);
});
});

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

View File

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