feat(slice scrollbar): Integrate ViewportSliceProgressScrollbar with customizations and docs (#5960)
Co-authored-by: Dan Rukas <dan.rukas@gmail.com>
This commit is contained in:
1 parent
8a2fee4d8a
commit
8fc0dc16e9
46 files changed
+1730
-279
No files matched your search
@@ -9,8 +9,8 @@ import React, {
|
||||
Children,
|
||||
isValidElement,
|
||||
} from 'react';
|
||||
import { getIndicatorLayout } from './utils';
|
||||
import { SmartScrollbarIndicator } from './SmartScrollbarIndicator';
|
||||
import { DEFAULT_INDICATOR_CONFIG } from './defaultSmartScrollbarIndicatorConfig';
|
||||
|
||||
// ── Child validation ────────────────────────────────────────────
|
||||
function validateChildren(children: React.ReactNode): void {
|
||||
@@ -33,19 +33,56 @@ function validateChildren(children: React.ReactNode): void {
|
||||
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;
|
||||
|
||||
/** Package-internal type; not re-exported from `@ohif/ui-next` barrels. */
|
||||
export interface SmartScrollbarIndicatorConfig {
|
||||
totalWidth?: number;
|
||||
totalHeight?: number;
|
||||
renderIndicator?: (react: typeof React) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a raw record payload into partial `SmartScrollbarIndicatorConfig`.
|
||||
*
|
||||
* Supported keys on `raw`:
|
||||
* - `totalWidth`, `totalHeight` — positive numbers
|
||||
* - `renderIndicator` — `(React) => ReactNode`
|
||||
*/
|
||||
function normalizeIndicatorRecord(
|
||||
raw: Record<string, unknown> | null | undefined
|
||||
): SmartScrollbarIndicatorConfig {
|
||||
if (raw == null) {
|
||||
return {};
|
||||
}
|
||||
const config: SmartScrollbarIndicatorConfig = {};
|
||||
if (typeof raw.totalWidth === 'number' && raw.totalWidth > 0) {
|
||||
config.totalWidth = raw.totalWidth;
|
||||
}
|
||||
if (typeof raw.totalHeight === 'number' && raw.totalHeight > 0) {
|
||||
config.totalHeight = raw.totalHeight;
|
||||
}
|
||||
|
||||
if (typeof raw.renderIndicator === 'function') {
|
||||
config.renderIndicator = raw.renderIndicator as (react: typeof React) => React.ReactNode;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// ── Contexts ───────────────────────────────────────────────────
|
||||
export interface SmartScrollbarLayoutContextValue {
|
||||
total: number;
|
||||
trackHeight: number;
|
||||
isLoading: boolean;
|
||||
isDragging: boolean;
|
||||
effectiveWidth: number;
|
||||
trackWidth: number;
|
||||
fillPadding: number;
|
||||
stableLayerEl: HTMLDivElement | null;
|
||||
indicatorTotalWidth: number;
|
||||
indicatorTotalHeight: number;
|
||||
renderIndicator: (react: typeof React) => React.ReactNode;
|
||||
}
|
||||
|
||||
const SmartScrollbarLayoutContext = createContext<SmartScrollbarLayoutContextValue | null>(null);
|
||||
@@ -74,6 +111,10 @@ interface SmartScrollbarProps {
|
||||
enableKeyboardNavigation?: boolean;
|
||||
'aria-label'?: string;
|
||||
className?: string;
|
||||
/**
|
||||
* Indicator payload parsed inside the component.
|
||||
*/
|
||||
indicator?: Record<string, unknown>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -86,10 +127,24 @@ export function SmartScrollbar({
|
||||
enableKeyboardNavigation = false,
|
||||
'aria-label': ariaLabel = 'Scroll position',
|
||||
className,
|
||||
indicator,
|
||||
children,
|
||||
}: SmartScrollbarProps) {
|
||||
validateChildren(children);
|
||||
|
||||
const resolvedIndicator = useMemo(() => {
|
||||
const defaultIndicatorConfig = DEFAULT_INDICATOR_CONFIG;
|
||||
const parsed = normalizeIndicatorRecord(indicator);
|
||||
if (parsed.totalWidth && parsed.totalHeight && parsed.renderIndicator) {
|
||||
return {
|
||||
totalWidth: parsed.totalWidth,
|
||||
totalHeight: parsed.totalHeight,
|
||||
renderIndicator: parsed.renderIndicator,
|
||||
};
|
||||
}
|
||||
return defaultIndicatorConfig;
|
||||
}, [indicator]);
|
||||
|
||||
// ── ResizeObserver for trackHeight ───────────────────────────
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [trackHeight, setTrackHeight] = useState(0);
|
||||
@@ -97,6 +152,12 @@ export function SmartScrollbar({
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const syncTrackHeight = () => {
|
||||
const measuredHeight = el.getBoundingClientRect().height;
|
||||
setTrackHeight(prev => (prev === measuredHeight ? prev : measuredHeight));
|
||||
};
|
||||
// Capture an immediate measurement so we don't rely solely on async ResizeObserver delivery.
|
||||
syncTrackHeight();
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setTrackHeight(entry.contentRect.height);
|
||||
});
|
||||
@@ -127,9 +188,8 @@ export function SmartScrollbar({
|
||||
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);
|
||||
// ── Hit zone: extend past `TRACK_WIDTH` on both sides when the pill is wider than the track ──
|
||||
const hitZoneSideExtension = Math.max(0, resolvedIndicator.totalWidth / 2 - TRACK_WIDTH / 2);
|
||||
|
||||
// ── Stable layer (for elements that shouldn't move during contraction) ──
|
||||
// Uses useState + callback ref so React triggers a re-render when the
|
||||
@@ -137,14 +197,21 @@ export function SmartScrollbar({
|
||||
const [stableLayerEl, setStableLayerEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
// ── Pointer helpers ──────────────────────────────────────────
|
||||
const clamp = useCallback(
|
||||
(val: number) => Math.max(0, Math.min(total - 1, val)),
|
||||
[total]
|
||||
);
|
||||
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));
|
||||
if (trackHeight <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Map pointer Y within the same padded fill strip used by fill/indicator.
|
||||
const fillTop = trackTopRef.current + FILL_PADDING;
|
||||
const fillHeight = trackHeight - FILL_PADDING * 2;
|
||||
if (fillHeight <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const ratio = Math.max(0, Math.min(1, (clientY - fillTop) / fillHeight));
|
||||
return Math.round(ratio * (total - 1));
|
||||
},
|
||||
[trackHeight, total]
|
||||
@@ -153,6 +220,9 @@ export function SmartScrollbar({
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
trackTopRef.current = e.currentTarget.getBoundingClientRect().top;
|
||||
if (trackHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDraggingRef.current = true;
|
||||
setIsDragging(true);
|
||||
@@ -160,7 +230,7 @@ export function SmartScrollbar({
|
||||
|
||||
onValueChange(clamp(indexFromPointerY(e.clientY)));
|
||||
},
|
||||
[clamp, indexFromPointerY, onValueChange]
|
||||
[clamp, indexFromPointerY, onValueChange, trackHeight]
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
@@ -216,15 +286,32 @@ export function SmartScrollbar({
|
||||
);
|
||||
|
||||
// ── Context values ───────────────────────────────────────────
|
||||
const layoutCtx = useMemo<SmartScrollbarLayoutContextValue>(() => ({
|
||||
total,
|
||||
trackHeight,
|
||||
isLoading,
|
||||
effectiveWidth,
|
||||
trackWidth: TRACK_WIDTH,
|
||||
fillPadding: FILL_PADDING,
|
||||
stableLayerEl,
|
||||
}), [total, trackHeight, isLoading, effectiveWidth, stableLayerEl]);
|
||||
const layoutCtx = useMemo<SmartScrollbarLayoutContextValue>(
|
||||
() => ({
|
||||
total,
|
||||
trackHeight,
|
||||
isLoading,
|
||||
isDragging,
|
||||
effectiveWidth,
|
||||
trackWidth: TRACK_WIDTH,
|
||||
fillPadding: FILL_PADDING,
|
||||
stableLayerEl,
|
||||
indicatorTotalWidth: resolvedIndicator.totalWidth,
|
||||
indicatorTotalHeight: resolvedIndicator.totalHeight,
|
||||
renderIndicator: resolvedIndicator.renderIndicator,
|
||||
}),
|
||||
[
|
||||
total,
|
||||
trackHeight,
|
||||
isLoading,
|
||||
isDragging,
|
||||
effectiveWidth,
|
||||
stableLayerEl,
|
||||
resolvedIndicator.totalWidth,
|
||||
resolvedIndicator.totalHeight,
|
||||
resolvedIndicator.renderIndicator,
|
||||
]
|
||||
);
|
||||
return (
|
||||
<SmartScrollbarLayoutContext.Provider value={layoutCtx}>
|
||||
<SmartScrollbarScrollContext.Provider value={value}>
|
||||
@@ -239,10 +326,10 @@ export function SmartScrollbar({
|
||||
tabIndex={0}
|
||||
className={className}
|
||||
style={{
|
||||
width: TRACK_WIDTH + hitZoneLeftExtension,
|
||||
width: TRACK_WIDTH + hitZoneSideExtension * 2,
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
marginLeft: -hitZoneLeftExtension,
|
||||
marginLeft: -hitZoneSideExtension,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
@@ -258,7 +345,7 @@ export function SmartScrollbar({
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
right: hitZoneSideExtension,
|
||||
top: 0,
|
||||
width: TRACK_WIDTH,
|
||||
height: trackHeight,
|
||||
|
||||
@@ -11,15 +11,17 @@ interface SmartScrollbarFillProps {
|
||||
* Recommended: manage `marked` + `version` together via `useByteArray()`.
|
||||
*/
|
||||
version: number;
|
||||
/** Fill color class. Defaults to `'bg-neutral/25'`. Override to customize (e.g. `'bg-primary/35'` for a viewed fill). */
|
||||
className?: string;
|
||||
/** Fill color class used while the scrollbar is in a loading state. Defaults to `'bg-neutral/50'`. */
|
||||
loadingClassName?: string;
|
||||
}
|
||||
|
||||
export const SmartScrollbarFill = React.memo(function SmartScrollbarFill({
|
||||
marked,
|
||||
version,
|
||||
className,
|
||||
loadingClassName,
|
||||
className = 'bg-neutral/25',
|
||||
loadingClassName = 'bg-neutral/50',
|
||||
}: SmartScrollbarFillProps) {
|
||||
const { trackHeight, effectiveWidth, fillPadding, isLoading } = useSmartScrollbarLayoutContext();
|
||||
|
||||
|
||||
@@ -1,79 +1,59 @@
|
||||
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)';
|
||||
import { computeIndicatorTopOffsetInFill } from './utils';
|
||||
|
||||
interface SmartScrollbarIndicatorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SmartScrollbarIndicator({ className }: SmartScrollbarIndicatorProps) {
|
||||
const { total, trackHeight, effectiveWidth, fillPadding } =
|
||||
useSmartScrollbarLayoutContext();
|
||||
const {
|
||||
total,
|
||||
trackHeight,
|
||||
effectiveWidth,
|
||||
fillPadding,
|
||||
indicatorTotalWidth,
|
||||
indicatorTotalHeight,
|
||||
renderIndicator,
|
||||
} = useSmartScrollbarLayoutContext();
|
||||
|
||||
const value = useSmartScrollbarScrollContext();
|
||||
|
||||
if (trackHeight === 0 || total <= 1) return null;
|
||||
if (trackHeight === 0 || total <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { totalWidth, totalHeight, fillWidth, fillHeight, leftPos } = getIndicatorLayout(
|
||||
effectiveWidth,
|
||||
INDICATOR_SIZE,
|
||||
BORDER_WIDTH
|
||||
);
|
||||
// Horizontal: center on the contracting inner width (not the full physical track).
|
||||
const leftPos = effectiveWidth / 2 - indicatorTotalWidth / 2;
|
||||
|
||||
const offsetY = (totalHeight - INDICATOR_SIZE) / 2;
|
||||
const fillAreaTop = fillPadding;
|
||||
// Vertical: same fill strip as SmartScrollbarFill — full track height minus padding.
|
||||
const pixelCount = Math.max(0, Math.floor(trackHeight - fillPadding * 2));
|
||||
if (pixelCount === 0) return null;
|
||||
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;
|
||||
const topOffsetInFill = computeIndicatorTopOffsetInFill(
|
||||
clampedValue,
|
||||
total,
|
||||
pixelCount,
|
||||
indicatorTotalHeight
|
||||
);
|
||||
const topPos = fillAreaTop + topOffsetInFill;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-none absolute ${className ?? ''}`}
|
||||
style={{
|
||||
left: leftPos,
|
||||
top: y - offsetY,
|
||||
width: totalWidth,
|
||||
height: totalHeight,
|
||||
top: topPos,
|
||||
width: indicatorTotalWidth,
|
||||
height: indicatorTotalHeight,
|
||||
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>
|
||||
{renderIndicator(React)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { type SmartScrollbarIndicatorConfig } from './SmartScrollbar';
|
||||
|
||||
/** Ring inset for the default pill SVG (outer size is indicator total width × height). */
|
||||
const BORDER_WIDTH = 1;
|
||||
|
||||
const INDICATOR_COLOR = 'hsl(var(--foreground) / 0.9)';
|
||||
const BORDER_COLOR = 'hsl(var(--neutral) / 0.9)';
|
||||
const TOTAL_WIDTH = 12;
|
||||
const TOTAL_HEIGHT = 7;
|
||||
|
||||
function DefaultIndicator() {
|
||||
const borderWidth = BORDER_WIDTH;
|
||||
const fillWidth = Math.max(0, TOTAL_WIDTH - borderWidth * 2);
|
||||
const fillHeight = Math.max(0, TOTAL_HEIGHT - borderWidth * 2);
|
||||
return (
|
||||
<svg
|
||||
width={TOTAL_WIDTH}
|
||||
height={TOTAL_HEIGHT}
|
||||
viewBox={`0 0 ${TOTAL_WIDTH} ${TOTAL_HEIGHT}`}
|
||||
>
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={TOTAL_WIDTH}
|
||||
height={TOTAL_HEIGHT}
|
||||
rx={TOTAL_HEIGHT / 2}
|
||||
ry={TOTAL_HEIGHT / 2}
|
||||
fill={BORDER_COLOR}
|
||||
/>
|
||||
<rect
|
||||
x={borderWidth}
|
||||
y={borderWidth}
|
||||
width={fillWidth}
|
||||
height={fillHeight}
|
||||
rx={fillHeight / 2}
|
||||
ry={fillHeight / 2}
|
||||
fill={INDICATOR_COLOR}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const DEFAULT_INDICATOR_CONFIG: SmartScrollbarIndicatorConfig = {
|
||||
totalWidth: TOTAL_WIDTH,
|
||||
totalHeight: TOTAL_HEIGHT,
|
||||
renderIndicator: (_react: typeof React) => <DefaultIndicator />,
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
export { SmartScrollbar, useSmartScrollbarLayoutContext, useSmartScrollbarScrollContext } from './SmartScrollbar';
|
||||
export type { SmartScrollbarLayoutContextValue } from './SmartScrollbar';
|
||||
export {
|
||||
SmartScrollbar,
|
||||
useSmartScrollbarLayoutContext,
|
||||
useSmartScrollbarScrollContext,
|
||||
} from './SmartScrollbar';
|
||||
export { SmartScrollbarTrack } from './SmartScrollbarTrack';
|
||||
export { SmartScrollbarFill } from './SmartScrollbarFill';
|
||||
export { SmartScrollbarIndicator } from './SmartScrollbarIndicator';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import debounce from 'lodash.debounce';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface ByteArrayHandle {
|
||||
bytes: Uint8Array;
|
||||
@@ -16,45 +15,68 @@ export interface ByteArrayHandle {
|
||||
* 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).
|
||||
* @param batchIntervalMs - When > 0, writes are coalesced into a scheduled
|
||||
* flush: the first write starts a timer, the next
|
||||
* flush bumps `version`, and the timer stops. New
|
||||
* writes start a new interval window. Omit or pass 0
|
||||
* for immediate re-renders on every write.
|
||||
*/
|
||||
export function useByteArray(size: number, debounceMs = 0): ByteArrayHandle {
|
||||
export function useByteArray(size: number, batchIntervalMs = 0): ByteArrayHandle {
|
||||
const bytesRef = useRef(new Uint8Array(size));
|
||||
const countRef = useRef(0);
|
||||
const [version, setVersion] = useState(0);
|
||||
const timeoutIdRef = useRef<number | null>(null);
|
||||
|
||||
// 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]
|
||||
);
|
||||
const clearScheduledFlush = useCallback(() => {
|
||||
if (timeoutIdRef.current !== null) {
|
||||
window.clearTimeout(timeoutIdRef.current);
|
||||
timeoutIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => debouncedBump?.cancel();
|
||||
}, [debouncedBump]);
|
||||
const flushScheduledVersion = useCallback(() => {
|
||||
// End this timeout window after the scheduled flush.
|
||||
clearScheduledFlush();
|
||||
setVersion(v => v + 1);
|
||||
}, [clearScheduledFlush]);
|
||||
|
||||
// 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();
|
||||
// Drop any in-flight timeout window when resetting the underlying array.
|
||||
clearScheduledFlush();
|
||||
bytesRef.current = new Uint8Array(size);
|
||||
countRef.current = 0;
|
||||
setVersion(v => v + 1);
|
||||
}, [size, debouncedBump]);
|
||||
}, [size, clearScheduledFlush]);
|
||||
|
||||
useEffect(() => {
|
||||
// If timing changes mid-window, restart that timeout using the new timing.
|
||||
const pendingTimeoutId = timeoutIdRef.current;
|
||||
clearScheduledFlush();
|
||||
if (batchIntervalMs <= 0) {
|
||||
if (pendingTimeoutId !== null) {
|
||||
setVersion(v => v + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pendingTimeoutId !== null) {
|
||||
timeoutIdRef.current = window.setTimeout(flushScheduledVersion, batchIntervalMs);
|
||||
}
|
||||
return () => clearScheduledFlush();
|
||||
}, [batchIntervalMs, clearScheduledFlush, flushScheduledVersion]);
|
||||
|
||||
const bump = useCallback(() => {
|
||||
if (debouncedBump) {
|
||||
debouncedBump();
|
||||
} else {
|
||||
if (batchIntervalMs <= 0) {
|
||||
setVersion(v => v + 1);
|
||||
return;
|
||||
}
|
||||
}, [debouncedBump]);
|
||||
|
||||
if (timeoutIdRef.current === null) {
|
||||
timeoutIdRef.current = window.setTimeout(flushScheduledVersion, batchIntervalMs);
|
||||
}
|
||||
}, [batchIntervalMs, flushScheduledVersion]);
|
||||
|
||||
const setByte = useCallback(
|
||||
(index: number) => {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { computeContiguousRuns, computePixelFilledFromMarked } from './utils';
|
||||
import {
|
||||
computeContiguousRuns,
|
||||
computeIndicatorTopOffsetInFill,
|
||||
computePixelFilledFromMarked,
|
||||
} from './utils';
|
||||
|
||||
function u8(values: number[]): Uint8Array {
|
||||
return new Uint8Array(values);
|
||||
@@ -57,6 +61,43 @@ describe('computePixelFilledFromMarked', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeIndicatorTopOffsetInFill', () => {
|
||||
it('top-aligns first bucket, centers middle, bottom-aligns last (pixelCount > total)', () => {
|
||||
const total = 3;
|
||||
const pixelCount = 9;
|
||||
const indicatorHeight = 2;
|
||||
// Buckets: [0,3), [3,6), [6,9)
|
||||
expect(computeIndicatorTopOffsetInFill(0, total, pixelCount, indicatorHeight)).toBe(0);
|
||||
expect(computeIndicatorTopOffsetInFill(1, total, pixelCount, indicatorHeight)).toBe(3);
|
||||
expect(computeIndicatorTopOffsetInFill(2, total, pixelCount, indicatorHeight)).toBe(7);
|
||||
});
|
||||
|
||||
it('top-aligns to fill pixel row when total >= pixelCount (slices > pixels)', () => {
|
||||
const total = 10;
|
||||
const pixelCount = 3;
|
||||
const indicatorHeight = 1;
|
||||
// Same buckets as computePixelFilledFromMarked: row0 [0,3), row1 [3,6), row2 [6,10)
|
||||
expect(computeIndicatorTopOffsetInFill(0, total, pixelCount, indicatorHeight)).toBe(0);
|
||||
expect(computeIndicatorTopOffsetInFill(2, total, pixelCount, indicatorHeight)).toBe(0);
|
||||
expect(computeIndicatorTopOffsetInFill(3, total, pixelCount, indicatorHeight)).toBe(1);
|
||||
expect(computeIndicatorTopOffsetInFill(5, total, pixelCount, indicatorHeight)).toBe(1);
|
||||
expect(computeIndicatorTopOffsetInFill(6, total, pixelCount, indicatorHeight)).toBe(2);
|
||||
expect(computeIndicatorTopOffsetInFill(9, total, pixelCount, indicatorHeight)).toBe(2);
|
||||
});
|
||||
|
||||
it('clamps when indicator is taller than remaining fill space', () => {
|
||||
const total = 2;
|
||||
const pixelCount = 5;
|
||||
const indicatorHeight = 10;
|
||||
expect(computeIndicatorTopOffsetInFill(0, total, pixelCount, indicatorHeight)).toBe(0);
|
||||
expect(computeIndicatorTopOffsetInFill(1, total, pixelCount, indicatorHeight)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when pixelCount is 0', () => {
|
||||
expect(computeIndicatorTopOffsetInFill(0, 5, 0, 4)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeContiguousRuns', () => {
|
||||
it('returns [] for empty input', () => {
|
||||
expect(computeContiguousRuns(new Uint8Array(0))).toEqual([]);
|
||||
|
||||
@@ -26,6 +26,52 @@ export function computeContiguousRuns(bytes: Uint8Array): ContiguousRun[] {
|
||||
return runs;
|
||||
}
|
||||
|
||||
/**
|
||||
* When `total >= pixelRowCount`: half-open item index range `[itemStart, itemEnd)`
|
||||
* covered by fill pixel row `pixelIndex`. `pixelRowCount` is `floor(pixelCount)` from callers.
|
||||
* Shared by `computePixelFilledFromMarked` and `computeIndicatorTopOffsetInFill` — keep in sync.
|
||||
*/
|
||||
function itemRangeForPixelRow(
|
||||
pixelIndex: number,
|
||||
total: number,
|
||||
pixelRowCount: number
|
||||
): { itemStart: number; itemEnd: number } {
|
||||
const itemStart = Math.floor((pixelIndex * total) / pixelRowCount);
|
||||
const itemEnd = Math.floor(((pixelIndex + 1) * total) / pixelRowCount);
|
||||
return { itemStart, itemEnd };
|
||||
}
|
||||
|
||||
/**
|
||||
* When `total >= pixelRowCount`: which fill pixel row owns `itemIndex` for the same
|
||||
* partition as `itemRangeForPixelRow` (inverse of scanning rows until
|
||||
* `itemIndex ∈ [itemStart, itemEnd)`).
|
||||
*
|
||||
* Readable form: `ceil((itemIndex + 1) * pixelRowCount / total) - 1`, clamped to `[0, pixelRowCount - 1]`.
|
||||
*/
|
||||
function pixelRowIndexForItemDownsample(
|
||||
itemIndex: number,
|
||||
total: number,
|
||||
pixelRowCount: number
|
||||
): number {
|
||||
const rowIndex = Math.ceil(((itemIndex + 1) * pixelRowCount) / total) - 1;
|
||||
return Math.max(0, Math.min(pixelRowCount - 1, rowIndex));
|
||||
}
|
||||
|
||||
/**
|
||||
* When `pixelRowCount > total`: half-open pixel row range `[pixelStart, pixelEnd)`
|
||||
* covered by `itemIndex`. `pixelRowCount` is `floor(pixelCount)` from callers.
|
||||
* Shared by `computePixelFilledFromMarked` and `computeIndicatorTopOffsetInFill` — keep in sync.
|
||||
*/
|
||||
function pixelSpanForItem(
|
||||
itemIndex: number,
|
||||
total: number,
|
||||
pixelRowCount: number
|
||||
): { pixelStart: number; pixelEnd: number } {
|
||||
const pixelStart = Math.floor((itemIndex * pixelRowCount) / total);
|
||||
const pixelEnd = Math.floor(((itemIndex + 1) * pixelRowCount) / total);
|
||||
return { pixelStart, pixelEnd };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -41,20 +87,19 @@ export function computePixelFilledFromMarked(
|
||||
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 pixelRowCount = Math.max(0, Math.floor(pixelCount));
|
||||
if (pixelRowCount === 0 || total <= 0) return new Uint8Array(0);
|
||||
|
||||
const pixelFilled = new Uint8Array(count);
|
||||
const pixelFilled = new Uint8Array(pixelRowCount);
|
||||
|
||||
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;
|
||||
if (total >= pixelRowCount) {
|
||||
for (let pixelIndex = 0; pixelIndex < pixelRowCount; pixelIndex++) {
|
||||
const { itemStart, itemEnd } = itemRangeForPixelRow(pixelIndex, total, pixelRowCount);
|
||||
if (itemEnd <= itemStart) continue;
|
||||
|
||||
let filled = 1;
|
||||
for (let itemIndex = start; itemIndex < end; itemIndex++) {
|
||||
if (marked[itemIndex] === 0) {
|
||||
for (let i = itemStart; i < itemEnd; i++) {
|
||||
if (marked[i] === 0) {
|
||||
filled = 0;
|
||||
break;
|
||||
}
|
||||
@@ -64,9 +109,8 @@ export function computePixelFilledFromMarked(
|
||||
} 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++) {
|
||||
const { pixelStart, pixelEnd } = pixelSpanForItem(itemIndex, total, pixelRowCount);
|
||||
for (let pixel = pixelStart; pixel < pixelEnd; pixel++) {
|
||||
pixelFilled[pixel] = 1;
|
||||
}
|
||||
}
|
||||
@@ -76,22 +120,49 @@ export function computePixelFilledFromMarked(
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the indicator's total visual dimensions and horizontal position.
|
||||
* Design 27: pill shape, center position, 1px border.
|
||||
* Vertical offset (px) of the indicator within the fill strip `[0, pixelCount)`,
|
||||
* clamped so the indicator stays inside the track.
|
||||
*
|
||||
* - When `total >= pixelCount` (many slices, few pixel rows): same partition as
|
||||
* `computePixelFilledFromMarked` (per-pixel item buckets); **top-align** the
|
||||
* indicator to that row (then clamp so the thumb does not overflow the strip).
|
||||
* - When `pixelCount > total`: same per-item pixel spans as the fill’s upsample
|
||||
* branch; first item top-aligned, last bottom-aligned, middle centered in bucket.
|
||||
* - Zero-height bucket (upsample only): top-aligned `pixelStart` from `pixelSpanForItem`.
|
||||
*/
|
||||
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;
|
||||
export function computeIndicatorTopOffsetInFill(
|
||||
itemIndex: number,
|
||||
total: number,
|
||||
pixelCount: number,
|
||||
indicatorHeight: number
|
||||
): number {
|
||||
const pixelRowCount = Math.max(0, Math.floor(pixelCount));
|
||||
const indicatorHeightPx = Math.max(0, Math.floor(indicatorHeight));
|
||||
const maxTopInFill = Math.max(0, pixelRowCount - indicatorHeightPx);
|
||||
if (pixelRowCount === 0 || total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const centerX = trackWidth / 2;
|
||||
const leftPos = centerX - totalWidth / 2;
|
||||
const clamp = (x: number) => Math.max(0, Math.min(maxTopInFill, x));
|
||||
|
||||
return { totalWidth, totalHeight, fillWidth, fillHeight, leftPos };
|
||||
if (total >= pixelRowCount) {
|
||||
const pixelRowIndex = pixelRowIndexForItemDownsample(itemIndex, total, pixelRowCount);
|
||||
return clamp(pixelRowIndex);
|
||||
} else {
|
||||
const { pixelStart, pixelEnd } = pixelSpanForItem(itemIndex, total, pixelRowCount);
|
||||
const bucketHeight = pixelEnd - pixelStart;
|
||||
|
||||
let topOffset: number;
|
||||
if (bucketHeight <= 0) {
|
||||
topOffset = pixelStart;
|
||||
} else if (itemIndex === 0) {
|
||||
topOffset = pixelStart;
|
||||
} else if (itemIndex === total - 1) {
|
||||
topOffset = pixelEnd - indicatorHeightPx;
|
||||
} else {
|
||||
topOffset = pixelStart + Math.floor((bucketHeight - indicatorHeightPx) / 2);
|
||||
}
|
||||
|
||||
return clamp(topOffset);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const locationClasses = {
|
||||
),
|
||||
[ViewportActionCornersLocations.topRight]: classNames(
|
||||
commonClasses,
|
||||
'absolute top-[4px] right-[16px] right-viewport-scrollbar'
|
||||
'absolute top-[4px] right-viewport-scrollbar'
|
||||
),
|
||||
[ViewportActionCornersLocations.bottomLeft]: classNames(
|
||||
commonClasses,
|
||||
@@ -35,7 +35,7 @@ const locationClasses = {
|
||||
),
|
||||
[ViewportActionCornersLocations.bottomRight]: classNames(
|
||||
commonClasses,
|
||||
'absolute bottom-[3px] right-[16px] right-viewport-scrollbar'
|
||||
'absolute bottom-[3px] right-viewport-scrollbar'
|
||||
),
|
||||
[ViewportActionCornersLocations.topMiddle]: classNames(
|
||||
commonClasses,
|
||||
@@ -52,7 +52,7 @@ const locationClasses = {
|
||||
),
|
||||
[ViewportActionCornersLocations.rightMiddle]: classNames(
|
||||
commonClasses,
|
||||
'absolute right-[16px] top-1/2 -translate-y-1/2 right-viewport-scrollbar'
|
||||
'absolute top-1/2 -translate-y-1/2 right-viewport-scrollbar'
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -31,14 +31,12 @@ function ViewportOverlay({ topLeft, topRight, bottomRight, bottomLeft, color = '
|
||||
<div
|
||||
data-cy="viewport-overlay-top-right"
|
||||
className={classNames(overlay, classes.topRight)}
|
||||
style={{ transform: 'translateX(9px)' }}
|
||||
>
|
||||
{topRight}
|
||||
</div>
|
||||
<div
|
||||
data-cy="viewport-overlay-bottom-right"
|
||||
className={classNames(overlay, classes.bottomRight)}
|
||||
style={{ transform: 'translateX(6px)' }}
|
||||
>
|
||||
{bottomRight}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user