diff --git a/platform/ui-next/jest.config.js b/platform/ui-next/jest.config.js new file mode 100644 index 00000000000..b34c25aafd0 --- /dev/null +++ b/platform/ui-next/jest.config.js @@ -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/'], +}; diff --git a/platform/ui-next/package.json b/platform/ui-next/package.json index e854b1cbbb2..694c6f5ad6c 100644 --- a/platform/ui-next/package.json +++ b/platform/ui-next/package.json @@ -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" }, diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx new file mode 100644 index 00000000000..1ace166a444 --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx @@ -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: 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(null); +const SmartScrollbarScrollContext = createContext(null); + +export function useSmartScrollbarLayoutContext(): SmartScrollbarLayoutContextValue { + const ctx = useContext(SmartScrollbarLayoutContext); + if (!ctx) + throw new Error('SmartScrollbar compound components must be used inside '); + return ctx; +} + +export function useSmartScrollbarScrollContext(): number { + const value = useContext(SmartScrollbarScrollContext); + if (value === null) + throw new Error('SmartScrollbar compound components must be used inside '); + 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(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(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(() => ({ + total, + trackHeight, + isLoading, + effectiveWidth, + trackWidth: TRACK_WIDTH, + fillPadding: FILL_PADDING, + stableLayerEl, + }), [total, trackHeight, isLoading, effectiveWidth, stableLayerEl]); + return ( + + +
setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerUp} + onKeyDown={enableKeyboardNavigation ? handleKeyDown : undefined} + > + {trackHeight > 0 && ( +
+
+ {children} +
+ {/* 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. */} +
+
+ )} +
+ + + ); +} diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx new file mode 100644 index 00000000000..dc19ed2c025 --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarEndpoints.tsx @@ -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( + + {/* Top cap */} + + {/* Bottom cap */} + + , + stableLayerEl + ); +}); diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx new file mode 100644 index 00000000000..92dbb752b35 --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarFill.tsx @@ -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 ( +
+ ); + })} + + ); +}); diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx new file mode 100644 index 00000000000..08fc375772b --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarIndicator.tsx @@ -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 ( +
+ + {/* Border rect */} + + {/* Fill rect */} + + +
+ ); +} diff --git a/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx new file mode 100644 index 00000000000..640d713749e --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/SmartScrollbarTrack.tsx @@ -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 ( + + + + + + + + + + + + ); +} + +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 ( +
+ {dotGridMounted && ( +
+ +
+ )} + {children} +
+ ); +}); diff --git a/platform/ui-next/src/components/SmartScrollbar/index.ts b/platform/ui-next/src/components/SmartScrollbar/index.ts new file mode 100644 index 00000000000..1137a80c54c --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/index.ts @@ -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'; diff --git a/platform/ui-next/src/components/SmartScrollbar/useByteArray.ts b/platform/ui-next/src/components/SmartScrollbar/useByteArray.ts new file mode 100644 index 00000000000..84024fce095 --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/useByteArray.ts @@ -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, + }; +} diff --git a/platform/ui-next/src/components/SmartScrollbar/utils.test.ts b/platform/ui-next/src/components/SmartScrollbar/utils.test.ts new file mode 100644 index 00000000000..c03434c5ac9 --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/utils.test.ts @@ -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 }]); + }); +}); diff --git a/platform/ui-next/src/components/SmartScrollbar/utils.ts b/platform/ui-next/src/components/SmartScrollbar/utils.ts new file mode 100644 index 00000000000..e6467094fed --- /dev/null +++ b/platform/ui-next/src/components/SmartScrollbar/utils.ts @@ -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 }; +} diff --git a/platform/ui-next/src/components/index.ts b/platform/ui-next/src/components/index.ts index 5fc7af79029..699d2a14a87 100644 --- a/platform/ui-next/src/components/index.ts +++ b/platform/ui-next/src/components/index.ts @@ -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, };