perf(ui): streamline overlay scrollbar updates
This commit is contained in:
@@ -391,7 +391,13 @@ const ChatViewport = React.memo(({
|
|||||||
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
</ScrollShadow>
|
</ScrollShadow>
|
||||||
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
|
<OverlayScrollbar
|
||||||
|
containerRef={scrollRef}
|
||||||
|
disableHorizontal
|
||||||
|
suppressVisibility={isProgrammaticFollowActive}
|
||||||
|
userIntentOnly
|
||||||
|
observeMutations={false}
|
||||||
|
/>
|
||||||
{showPromptNavigator && promptTurnIds.length >= 2 ? (
|
{showPromptNavigator && promptTurnIds.length >= 2 ? (
|
||||||
<PromptNavigatorRail
|
<PromptNavigatorRail
|
||||||
turnIds={promptTurnIds}
|
turnIds={promptTurnIds}
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||||
|
import { Window } from 'happy-dom';
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
|
||||||
|
import { OverlayScrollbar } from './OverlayScrollbar';
|
||||||
|
|
||||||
|
class TestResizeObserver implements ResizeObserver {
|
||||||
|
static instances: TestResizeObserver[] = [];
|
||||||
|
|
||||||
|
private readonly callback: ResizeObserverCallback;
|
||||||
|
disconnectCount = 0;
|
||||||
|
|
||||||
|
constructor(callback: ResizeObserverCallback) {
|
||||||
|
this.callback = callback;
|
||||||
|
TestResizeObserver.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.disconnectCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
observe(): void {}
|
||||||
|
unobserve(): void {}
|
||||||
|
|
||||||
|
trigger(): void {
|
||||||
|
this.callback([], this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OverlayScrollbar', () => {
|
||||||
|
let windowInstance: Window;
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let scroller: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
let containerRef: React.RefObject<HTMLElement | null>;
|
||||||
|
let scrollTop: number;
|
||||||
|
let scrollbarCommits: number;
|
||||||
|
let verticalLayoutReads: number;
|
||||||
|
let horizontalLayoutReads: number;
|
||||||
|
let nextFrameId: number;
|
||||||
|
let pendingFrames: Map<number, FrameRequestCallback>;
|
||||||
|
|
||||||
|
const flushFrames = async () => {
|
||||||
|
await act(async () => {
|
||||||
|
const frames = Array.from(pendingFrames.values());
|
||||||
|
pendingFrames.clear();
|
||||||
|
frames.forEach((callback) => callback(0));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderScrollbar = async (props: Partial<React.ComponentProps<typeof OverlayScrollbar>> = {}) => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<React.Profiler id="overlay-scrollbar" onRender={() => { scrollbarCommits += 1; }}>
|
||||||
|
<OverlayScrollbar
|
||||||
|
containerRef={containerRef}
|
||||||
|
disableHorizontal
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</React.Profiler>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushFrames();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
windowInstance = new Window();
|
||||||
|
pendingFrames = new Map();
|
||||||
|
nextFrameId = 1;
|
||||||
|
TestResizeObserver.instances = [];
|
||||||
|
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
window: windowInstance,
|
||||||
|
document: windowInstance.document,
|
||||||
|
HTMLElement: windowInstance.HTMLElement,
|
||||||
|
Element: windowInstance.Element,
|
||||||
|
Node: windowInstance.Node,
|
||||||
|
PointerEvent: windowInstance.PointerEvent,
|
||||||
|
ResizeObserver: TestResizeObserver,
|
||||||
|
IS_REACT_ACT_ENVIRONMENT: true,
|
||||||
|
requestAnimationFrame: (callback: FrameRequestCallback) => {
|
||||||
|
const id = nextFrameId++;
|
||||||
|
pendingFrames.set(id, callback);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
cancelAnimationFrame: (id: number) => {
|
||||||
|
pendingFrames.delete(id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
host = document.createElement('div');
|
||||||
|
scroller = document.createElement('div');
|
||||||
|
document.body.append(host, scroller);
|
||||||
|
root = createRoot(host);
|
||||||
|
containerRef = { current: scroller };
|
||||||
|
scrollTop = 0;
|
||||||
|
scrollbarCommits = 0;
|
||||||
|
verticalLayoutReads = 0;
|
||||||
|
horizontalLayoutReads = 0;
|
||||||
|
|
||||||
|
Object.defineProperties(scroller, {
|
||||||
|
clientHeight: {
|
||||||
|
configurable: true,
|
||||||
|
get: () => {
|
||||||
|
verticalLayoutReads += 1;
|
||||||
|
return 100;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scrollHeight: {
|
||||||
|
configurable: true,
|
||||||
|
get: () => {
|
||||||
|
verticalLayoutReads += 1;
|
||||||
|
return 500;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scrollTop: {
|
||||||
|
configurable: true,
|
||||||
|
get: () => scrollTop,
|
||||||
|
set: (value: number) => {
|
||||||
|
scrollTop = value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
clientWidth: {
|
||||||
|
configurable: true,
|
||||||
|
get: () => {
|
||||||
|
horizontalLayoutReads += 1;
|
||||||
|
return 100;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scrollWidth: {
|
||||||
|
configurable: true,
|
||||||
|
get: () => {
|
||||||
|
horizontalLayoutReads += 1;
|
||||||
|
return 100;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
windowInstance.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moves the thumb without rereading layout during steady scrolling', async () => {
|
||||||
|
await renderScrollbar();
|
||||||
|
scrollbarCommits = 0;
|
||||||
|
verticalLayoutReads = 0;
|
||||||
|
horizontalLayoutReads = 0;
|
||||||
|
|
||||||
|
scrollTop = 200;
|
||||||
|
scroller.dispatchEvent(new window.Event('scroll'));
|
||||||
|
await flushFrames();
|
||||||
|
|
||||||
|
const thumb = host.querySelector<HTMLElement>('[data-overlay-scrollbar-thumb="vertical"]');
|
||||||
|
expect(thumb?.style.transform).toBe('translate3d(0, 34px, 0)');
|
||||||
|
expect(thumb?.style.top).toBe('');
|
||||||
|
expect(verticalLayoutReads).toBe(0);
|
||||||
|
expect(horizontalLayoutReads).toBe(0);
|
||||||
|
expect(scrollbarCommits).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coalesces scroll and resize updates into one animation frame', async () => {
|
||||||
|
await renderScrollbar();
|
||||||
|
|
||||||
|
scroller.dispatchEvent(new window.Event('scroll'));
|
||||||
|
TestResizeObserver.instances[0]?.trigger();
|
||||||
|
|
||||||
|
expect(pendingFrames.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reads both axes before writing updated thumb sizes', async () => {
|
||||||
|
let clientHeight = 100;
|
||||||
|
let verticalHeightDuringHorizontalRead = '';
|
||||||
|
let captureHorizontalRead = false;
|
||||||
|
Object.defineProperties(scroller, {
|
||||||
|
clientHeight: { configurable: true, get: () => clientHeight },
|
||||||
|
clientWidth: {
|
||||||
|
configurable: true,
|
||||||
|
get: () => {
|
||||||
|
if (captureHorizontalRead) {
|
||||||
|
verticalHeightDuringHorizontalRead = host.querySelector<HTMLElement>(
|
||||||
|
'[data-overlay-scrollbar-thumb="vertical"]',
|
||||||
|
)?.style.height ?? '';
|
||||||
|
}
|
||||||
|
return 100;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scrollWidth: { configurable: true, get: () => 300 },
|
||||||
|
});
|
||||||
|
await renderScrollbar({ disableHorizontal: false });
|
||||||
|
|
||||||
|
clientHeight = 200;
|
||||||
|
captureHorizontalRead = true;
|
||||||
|
TestResizeObserver.instances[0]?.trigger();
|
||||||
|
await flushFrames();
|
||||||
|
|
||||||
|
const verticalThumb = host.querySelector<HTMLElement>('[data-overlay-scrollbar-thumb="vertical"]');
|
||||||
|
expect(verticalHeightDuringHorizontalRead).toBe('32px');
|
||||||
|
expect(verticalThumb?.style.height).toBe('73.6px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the resize observer connected when visibility is suppressed', async () => {
|
||||||
|
await renderScrollbar({ suppressVisibility: false });
|
||||||
|
expect(TestResizeObserver.instances).toHaveLength(1);
|
||||||
|
const disconnectCount = TestResizeObserver.instances[0]?.disconnectCount;
|
||||||
|
|
||||||
|
await renderScrollbar({ suppressVisibility: true });
|
||||||
|
|
||||||
|
expect(TestResizeObserver.instances).toHaveLength(1);
|
||||||
|
expect(TestResizeObserver.instances[0]?.disconnectCount).toBe(disconnectCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the rendered thumb travel when dragging', async () => {
|
||||||
|
await renderScrollbar();
|
||||||
|
const thumb = host.querySelector<HTMLElement>('[data-overlay-scrollbar-thumb="vertical"]');
|
||||||
|
if (!thumb) throw new Error('OverlayScrollbar did not render its vertical thumb');
|
||||||
|
|
||||||
|
thumb.setPointerCapture = () => {};
|
||||||
|
thumb.releasePointerCapture = () => {};
|
||||||
|
thumb.dispatchEvent(new window.PointerEvent('pointerdown', {
|
||||||
|
bubbles: true,
|
||||||
|
clientY: 0,
|
||||||
|
pointerId: 1,
|
||||||
|
}));
|
||||||
|
thumb.dispatchEvent(new window.PointerEvent('pointermove', {
|
||||||
|
bubbles: true,
|
||||||
|
clientY: 26,
|
||||||
|
pointerId: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(scrollTop).toBe(200);
|
||||||
|
thumb.dispatchEvent(new window.PointerEvent('pointerup', {
|
||||||
|
bubbles: true,
|
||||||
|
clientY: 26,
|
||||||
|
pointerId: 1,
|
||||||
|
}));
|
||||||
|
thumb.dispatchEvent(new window.PointerEvent('pointermove', {
|
||||||
|
bubbles: true,
|
||||||
|
clientY: 52,
|
||||||
|
pointerId: 1,
|
||||||
|
}));
|
||||||
|
expect(scrollTop).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the minimum thumb size within a short track', async () => {
|
||||||
|
Object.defineProperty(scroller, 'clientHeight', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => 40,
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderScrollbar();
|
||||||
|
|
||||||
|
const thumb = host.querySelector<HTMLElement>('[data-overlay-scrollbar-thumb="vertical"]');
|
||||||
|
expect(thumb?.style.height).toBe('24px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applies visibility suppression without reconnecting the scrollbar', async () => {
|
||||||
|
await renderScrollbar({ suppressVisibility: false });
|
||||||
|
const scrollbar = host.querySelector<HTMLElement>('.overlay-scrollbar');
|
||||||
|
if (!scrollbar) throw new Error('OverlayScrollbar did not render its container');
|
||||||
|
|
||||||
|
scroller.dispatchEvent(new window.Event('scroll'));
|
||||||
|
expect(scrollbar.dataset.visible).toBe('true');
|
||||||
|
|
||||||
|
await renderScrollbar({ suppressVisibility: true });
|
||||||
|
|
||||||
|
expect(scrollbar.dataset.visible).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows user-intent-only scrollbars for user scrolling but not programmatic scrolling', async () => {
|
||||||
|
await renderScrollbar({ userIntentOnly: true });
|
||||||
|
const scrollbar = host.querySelector<HTMLElement>('.overlay-scrollbar');
|
||||||
|
if (!scrollbar) throw new Error('OverlayScrollbar did not render its container');
|
||||||
|
|
||||||
|
scroller.dispatchEvent(new window.Event('scroll'));
|
||||||
|
expect(scrollbar.dataset.visible).toBe('false');
|
||||||
|
|
||||||
|
scroller.dispatchEvent(new window.WheelEvent('wheel'));
|
||||||
|
scroller.dispatchEvent(new window.Event('scroll'));
|
||||||
|
expect(scrollbar.dataset.visible).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -2,30 +2,340 @@ import React from "react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type OverlayScrollbarProps = {
|
type OverlayScrollbarProps = {
|
||||||
|
/** The authoritative scrolling element. Its identity must stay stable while mounted. */
|
||||||
containerRef: React.RefObject<HTMLElement | null>;
|
containerRef: React.RefObject<HTMLElement | null>;
|
||||||
|
/** Minimum thumb length in CSS pixels, capped to the available track. */
|
||||||
minThumbSize?: number;
|
minThumbSize?: number;
|
||||||
|
/** Time in milliseconds before an inactive thumb fades out. */
|
||||||
hideDelayMs?: number;
|
hideDelayMs?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Skips horizontal geometry reads and keeps the horizontal thumb hidden. */
|
||||||
disableHorizontal?: boolean;
|
disableHorizontal?: boolean;
|
||||||
|
/** Tracks direct-child replacement so newly mounted content remains size-observed. */
|
||||||
observeMutations?: boolean;
|
observeMutations?: boolean;
|
||||||
|
/** Hides the scrollbar during programmatic motion, except while the user is dragging it. */
|
||||||
suppressVisibility?: boolean;
|
suppressVisibility?: boolean;
|
||||||
|
/** Shows the scrollbar only after recent wheel, touch, keyboard, or thumb input. */
|
||||||
userIntentOnly?: boolean;
|
userIntentOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ThumbMetrics = {
|
type ScrollbarOptions = Required<Pick<
|
||||||
length: number;
|
OverlayScrollbarProps,
|
||||||
offset: number;
|
"minThumbSize" | "hideDelayMs" | "disableHorizontal" | "observeMutations" | "suppressVisibility" | "userIntentOnly"
|
||||||
};
|
>>;
|
||||||
|
|
||||||
const USER_SCROLL_INTENT_WINDOW_MS = 1000;
|
// The inset is part of both rendering and drag math; changing it must preserve that shared track.
|
||||||
const METRIC_EPSILON = 0.5;
|
const TRACK_INSET = 8;
|
||||||
const EMPTY_THUMB: ThumbMetrics = { length: 0, offset: 0 };
|
const USER_INTENT_DURATION_MS = 1000;
|
||||||
|
const SCROLL_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "]);
|
||||||
|
|
||||||
const isSameThumbMetrics = (a: ThumbMetrics, b: ThumbMetrics): boolean => {
|
function calculateThumb(viewportLength: number, contentLength: number, minThumbSize: number) {
|
||||||
return Math.abs(a.length - b.length) < METRIC_EPSILON && Math.abs(a.offset - b.offset) < METRIC_EPSILON;
|
const trackLength = Math.max(viewportLength - TRACK_INSET * 2, 0);
|
||||||
};
|
const scrollRange = Math.max(contentLength - viewportLength, 0);
|
||||||
|
if (trackLength === 0 || scrollRange === 0) return null;
|
||||||
|
|
||||||
const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
|
const length = Math.min(
|
||||||
|
trackLength,
|
||||||
|
Math.max(minThumbSize, (viewportLength / contentLength) * trackLength),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
length,
|
||||||
|
thumbPixelsPerScrollPixel: (trackLength - length) / scrollRange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindScrollbar(
|
||||||
|
container: HTMLElement,
|
||||||
|
root: HTMLDivElement,
|
||||||
|
verticalThumb: HTMLDivElement,
|
||||||
|
horizontalThumb: HTMLDivElement,
|
||||||
|
initialOptions: ScrollbarOptions,
|
||||||
|
) {
|
||||||
|
let options = initialOptions;
|
||||||
|
|
||||||
|
// One frame coalesces scroll, resize, and mutation signals. Measurement is requested only when sizes may change.
|
||||||
|
let frameId: number | null = null;
|
||||||
|
let needsMeasurement = true;
|
||||||
|
|
||||||
|
// Cached unit conversions let steady scrolling avoid layout-dependent size reads.
|
||||||
|
let verticalThumbPixelsPerScrollPixel = 0;
|
||||||
|
let horizontalThumbPixelsPerScrollPixel = 0;
|
||||||
|
|
||||||
|
// A single moving deadline avoids clearing and recreating a timer on every scroll event.
|
||||||
|
let hideTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let hideDeadlineMs = 0;
|
||||||
|
let lastUserIntentTimeMs = Number.NEGATIVE_INFINITY;
|
||||||
|
let pointerOverThumb = false;
|
||||||
|
|
||||||
|
// Drag state is the minimum snapshot needed to convert pointer travel back into a scroll offset.
|
||||||
|
let drag: {
|
||||||
|
axis: "vertical" | "horizontal";
|
||||||
|
pointerId: number;
|
||||||
|
pointerStartPx: number;
|
||||||
|
scrollStartPx: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
// Visibility is an imperative presentation detail; no React consumer needs it as state.
|
||||||
|
// React owns the stable elements; this binding exclusively owns their dynamic attributes and styles.
|
||||||
|
const setVisible = (visible: boolean) => {
|
||||||
|
const value = String(visible);
|
||||||
|
if (root.dataset.visible !== value) root.dataset.visible = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleHide = () => {
|
||||||
|
if (pointerOverThumb || drag || hideTimerId !== null) return;
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
hideTimerId = null;
|
||||||
|
if (pointerOverThumb || drag) return;
|
||||||
|
const delay = hideDeadlineMs - performance.now();
|
||||||
|
if (delay > 0) {
|
||||||
|
hideTimerId = setTimeout(hide, delay);
|
||||||
|
} else {
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
hideTimerId = setTimeout(hide, Math.max(hideDeadlineMs - performance.now(), 0));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Measurement is the cold path. Read every axis before writing either thumb to avoid forced layout.
|
||||||
|
const measureThumbs = () => {
|
||||||
|
const clientHeight = container.clientHeight;
|
||||||
|
const scrollHeight = container.scrollHeight;
|
||||||
|
const clientWidth = options.disableHorizontal ? 0 : container.clientWidth;
|
||||||
|
const scrollWidth = options.disableHorizontal ? 0 : container.scrollWidth;
|
||||||
|
const vertical = calculateThumb(clientHeight, scrollHeight, options.minThumbSize);
|
||||||
|
const horizontal = options.disableHorizontal
|
||||||
|
? null
|
||||||
|
: calculateThumb(clientWidth, scrollWidth, options.minThumbSize);
|
||||||
|
|
||||||
|
if (vertical) {
|
||||||
|
verticalThumbPixelsPerScrollPixel = vertical.thumbPixelsPerScrollPixel;
|
||||||
|
verticalThumb.hidden = false;
|
||||||
|
verticalThumb.style.height = `${vertical.length}px`;
|
||||||
|
} else {
|
||||||
|
verticalThumbPixelsPerScrollPixel = 0;
|
||||||
|
verticalThumb.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (horizontal) {
|
||||||
|
horizontalThumbPixelsPerScrollPixel = horizontal.thumbPixelsPerScrollPixel;
|
||||||
|
horizontalThumb.hidden = false;
|
||||||
|
horizontalThumb.style.width = `${horizontal.length}px`;
|
||||||
|
} else {
|
||||||
|
horizontalThumbPixelsPerScrollPixel = 0;
|
||||||
|
horizontalThumb.hidden = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const positionThumbs = () => {
|
||||||
|
// This is the hot path: cached scales leave only scroll offsets to read and transforms to write.
|
||||||
|
if (!verticalThumb.hidden) {
|
||||||
|
verticalThumb.style.transform = `translate3d(0, ${TRACK_INSET + container.scrollTop * verticalThumbPixelsPerScrollPixel}px, 0)`;
|
||||||
|
}
|
||||||
|
if (!horizontalThumb.hidden) {
|
||||||
|
horizontalThumb.style.transform = `translate3d(${TRACK_INSET + container.scrollLeft * horizontalThumbPixelsPerScrollPixel}px, 0, 0)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleUpdate = (measure = false) => {
|
||||||
|
needsMeasurement ||= measure;
|
||||||
|
if (frameId !== null) return;
|
||||||
|
|
||||||
|
// All event sources share one read-before-write pass per frame.
|
||||||
|
frameId = requestAnimationFrame(() => {
|
||||||
|
frameId = null;
|
||||||
|
if (needsMeasurement) {
|
||||||
|
needsMeasurement = false;
|
||||||
|
measureThumbs();
|
||||||
|
}
|
||||||
|
positionThumbs();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const markUserIntent = () => {
|
||||||
|
lastUserIntentTimeMs = performance.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scroll visibility is separate from positioning so hidden programmatic scrolling does no DOM work.
|
||||||
|
const onScroll = () => {
|
||||||
|
const shouldShow = drag
|
||||||
|
|| (!options.suppressVisibility
|
||||||
|
&& (!options.userIntentOnly
|
||||||
|
|| performance.now() - lastUserIntentTimeMs <= USER_INTENT_DURATION_MS));
|
||||||
|
|
||||||
|
if (!shouldShow) {
|
||||||
|
setVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleUpdate();
|
||||||
|
hideDeadlineMs = performance.now() + options.hideDelayMs;
|
||||||
|
setVisible(true);
|
||||||
|
scheduleHide();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (SCROLL_KEYS.has(event.key)) {
|
||||||
|
markUserIntent();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getThumbAxis = (target: EventTarget | null) => {
|
||||||
|
if (target === verticalThumb) return "vertical";
|
||||||
|
if (target === horizontalThumb) return "horizontal";
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Both thumbs delegate pointer interaction through the stable overlay root.
|
||||||
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
|
const axis = getThumbAxis(event.target);
|
||||||
|
if (!axis) return;
|
||||||
|
const thumb = axis === "vertical" ? verticalThumb : horizontalThumb;
|
||||||
|
drag = {
|
||||||
|
axis,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
pointerStartPx: axis === "vertical" ? event.clientY : event.clientX,
|
||||||
|
scrollStartPx: axis === "vertical" ? container.scrollTop : container.scrollLeft,
|
||||||
|
};
|
||||||
|
markUserIntent();
|
||||||
|
hideDeadlineMs = Number.POSITIVE_INFINITY;
|
||||||
|
if (hideTimerId !== null) {
|
||||||
|
clearTimeout(hideTimerId);
|
||||||
|
hideTimerId = null;
|
||||||
|
}
|
||||||
|
setVisible(true);
|
||||||
|
thumb.setPointerCapture(event.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (event: PointerEvent) => {
|
||||||
|
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||||
|
const pointer = drag.axis === "vertical" ? event.clientY : event.clientX;
|
||||||
|
const scale = drag.axis === "vertical"
|
||||||
|
? verticalThumbPixelsPerScrollPixel
|
||||||
|
: horizontalThumbPixelsPerScrollPixel;
|
||||||
|
if (scale <= 0) return;
|
||||||
|
const scrollOffset = drag.scrollStartPx + (pointer - drag.pointerStartPx) / scale;
|
||||||
|
if (drag.axis === "vertical") container.scrollTop = scrollOffset;
|
||||||
|
else container.scrollLeft = scrollOffset;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerEnd = (event: PointerEvent) => {
|
||||||
|
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||||
|
const thumb = drag.axis === "vertical" ? verticalThumb : horizontalThumb;
|
||||||
|
drag = null;
|
||||||
|
if (thumb.hasPointerCapture(event.pointerId)) thumb.releasePointerCapture(event.pointerId);
|
||||||
|
hideDeadlineMs = performance.now() + options.hideDelayMs;
|
||||||
|
scheduleHide();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerOver = (event: PointerEvent) => {
|
||||||
|
if (!getThumbAxis(event.target)) return;
|
||||||
|
pointerOverThumb = true;
|
||||||
|
if (hideTimerId !== null) {
|
||||||
|
clearTimeout(hideTimerId);
|
||||||
|
hideTimerId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerOut = (event: PointerEvent) => {
|
||||||
|
if (!getThumbAxis(event.target)) return;
|
||||||
|
pointerOverThumb = false;
|
||||||
|
hideDeadlineMs = performance.now() + options.hideDelayMs;
|
||||||
|
scheduleHide();
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
root.addEventListener("pointerdown", onPointerDown);
|
||||||
|
root.addEventListener("pointermove", onPointerMove);
|
||||||
|
root.addEventListener("pointerup", onPointerEnd);
|
||||||
|
root.addEventListener("pointercancel", onPointerEnd);
|
||||||
|
root.addEventListener("pointerover", onPointerOver);
|
||||||
|
root.addEventListener("pointerout", onPointerOut);
|
||||||
|
|
||||||
|
// ResizeObserver invalidates measurements; MutationObserver only keeps direct-child observation current.
|
||||||
|
const resizeObserver = globalThis.ResizeObserver
|
||||||
|
? new ResizeObserver(() => scheduleUpdate(true))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const observeSizes = () => {
|
||||||
|
if (!resizeObserver) return;
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
for (const child of container.children) {
|
||||||
|
resizeObserver.observe(child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
observeSizes();
|
||||||
|
|
||||||
|
const mutationObserver = globalThis.MutationObserver
|
||||||
|
? new MutationObserver(() => {
|
||||||
|
observeSizes();
|
||||||
|
scheduleUpdate(true);
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const setMutationObservation = (enabled: boolean) => {
|
||||||
|
mutationObserver?.disconnect();
|
||||||
|
if (enabled) mutationObserver?.observe(container, { childList: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setUserIntentListeners = (enabled: boolean) => {
|
||||||
|
if (enabled) {
|
||||||
|
container.addEventListener("wheel", markUserIntent, { passive: true });
|
||||||
|
container.addEventListener("touchstart", markUserIntent, { passive: true });
|
||||||
|
container.addEventListener("touchmove", markUserIntent, { passive: true });
|
||||||
|
container.addEventListener("keydown", onKeyDown);
|
||||||
|
} else {
|
||||||
|
container.removeEventListener("wheel", markUserIntent);
|
||||||
|
container.removeEventListener("touchstart", markUserIntent);
|
||||||
|
container.removeEventListener("touchmove", markUserIntent);
|
||||||
|
container.removeEventListener("keydown", onKeyDown);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setMutationObservation(options.observeMutations);
|
||||||
|
setUserIntentListeners(options.userIntentOnly);
|
||||||
|
root.dataset.visible = "false";
|
||||||
|
scheduleUpdate(true);
|
||||||
|
|
||||||
|
// Props update policy in place; only mounting and unmounting bind browser resources.
|
||||||
|
return {
|
||||||
|
update(nextOptions: ScrollbarOptions) {
|
||||||
|
const mustMeasure = nextOptions.disableHorizontal !== options.disableHorizontal
|
||||||
|
|| nextOptions.minThumbSize !== options.minThumbSize;
|
||||||
|
if (nextOptions.observeMutations !== options.observeMutations) {
|
||||||
|
setMutationObservation(nextOptions.observeMutations);
|
||||||
|
if (nextOptions.observeMutations) observeSizes();
|
||||||
|
}
|
||||||
|
if (nextOptions.userIntentOnly !== options.userIntentOnly) {
|
||||||
|
setUserIntentListeners(nextOptions.userIntentOnly);
|
||||||
|
}
|
||||||
|
options = nextOptions;
|
||||||
|
if (mustMeasure) scheduleUpdate(true);
|
||||||
|
if (options.disableHorizontal) horizontalThumb.hidden = true;
|
||||||
|
if (options.suppressVisibility && !drag) setVisible(false);
|
||||||
|
},
|
||||||
|
disconnect() {
|
||||||
|
if (frameId !== null) cancelAnimationFrame(frameId);
|
||||||
|
if (hideTimerId !== null) clearTimeout(hideTimerId);
|
||||||
|
container.removeEventListener("scroll", onScroll);
|
||||||
|
setUserIntentListeners(false);
|
||||||
|
root.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
root.removeEventListener("pointermove", onPointerMove);
|
||||||
|
root.removeEventListener("pointerup", onPointerEnd);
|
||||||
|
root.removeEventListener("pointercancel", onPointerEnd);
|
||||||
|
root.removeEventListener("pointerover", onPointerOver);
|
||||||
|
root.removeEventListener("pointerout", onPointerOut);
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
mutationObserver?.disconnect();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
||||||
containerRef,
|
containerRef,
|
||||||
minThumbSize = 32,
|
minThumbSize = 32,
|
||||||
hideDelayMs = 1000,
|
hideDelayMs = 1000,
|
||||||
@@ -35,328 +345,63 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
|
|||||||
suppressVisibility = false,
|
suppressVisibility = false,
|
||||||
userIntentOnly = false,
|
userIntentOnly = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [visible, setVisible] = React.useState(false);
|
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||||
const [vertical, setVertical] = React.useState<ThumbMetrics>({ length: 0, offset: 0 });
|
const verticalThumbRef = React.useRef<HTMLDivElement>(null);
|
||||||
const [horizontal, setHorizontal] = React.useState<ThumbMetrics>({ length: 0, offset: 0 });
|
const horizontalThumbRef = React.useRef<HTMLDivElement>(null);
|
||||||
const hideTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const bindingRef = React.useRef<ReturnType<typeof bindScrollbar> | null>(null);
|
||||||
const frameRef = React.useRef<number | null>(null);
|
const initialOptionsRef = React.useRef<ScrollbarOptions>({
|
||||||
const metricsFrameRef = React.useRef<number | null>(null);
|
minThumbSize,
|
||||||
const isDraggingRef = React.useRef(false);
|
hideDelayMs,
|
||||||
const isHoveringRef = React.useRef(false);
|
disableHorizontal,
|
||||||
const lastUserIntentAtRef = React.useRef(0);
|
observeMutations,
|
||||||
const dragStartRef = React.useRef<{
|
suppressVisibility,
|
||||||
pointerX: number;
|
userIntentOnly,
|
||||||
pointerY: number;
|
});
|
||||||
scrollTop: number;
|
|
||||||
scrollLeft: number;
|
|
||||||
}>({ pointerX: 0, pointerY: 0, scrollTop: 0, scrollLeft: 0 });
|
|
||||||
const dragAxisRef = React.useRef<"vertical" | "horizontal" | null>(null);
|
|
||||||
const observedElementsRef = React.useRef<Set<Element>>(new Set());
|
|
||||||
|
|
||||||
const updateMetrics = React.useCallback(() => {
|
React.useLayoutEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
const root = rootRef.current;
|
||||||
|
const verticalThumb = verticalThumbRef.current;
|
||||||
const { scrollHeight, clientHeight, scrollTop, scrollWidth, clientWidth, scrollLeft } = container;
|
const horizontalThumb = horizontalThumbRef.current;
|
||||||
const trackInset = 8;
|
if (!container || !root || !verticalThumb || !horizontalThumb) return;
|
||||||
|
|
||||||
let nextVertical: ThumbMetrics = EMPTY_THUMB;
|
|
||||||
if (scrollHeight > clientHeight) {
|
|
||||||
const trackLength = Math.max(clientHeight - trackInset * 2, 0);
|
|
||||||
const rawThumb = (clientHeight / scrollHeight) * trackLength;
|
|
||||||
const length = Math.max(minThumbSize, Math.min(trackLength, rawThumb));
|
|
||||||
const maxOffset = Math.max(trackLength - length, 0);
|
|
||||||
const maxScroll = Math.max(scrollHeight - clientHeight, 1);
|
|
||||||
const offset = (scrollTop / maxScroll) * maxOffset;
|
|
||||||
nextVertical = { length, offset };
|
|
||||||
}
|
|
||||||
setVertical((prev) => (isSameThumbMetrics(prev, nextVertical) ? prev : nextVertical));
|
|
||||||
|
|
||||||
let nextHorizontal: ThumbMetrics = EMPTY_THUMB;
|
|
||||||
if (!disableHorizontal && scrollWidth > clientWidth) {
|
|
||||||
const trackLength = Math.max(clientWidth - trackInset * 2, 0);
|
|
||||||
const rawThumb = (clientWidth / scrollWidth) * trackLength;
|
|
||||||
const length = Math.max(minThumbSize, Math.min(trackLength, rawThumb));
|
|
||||||
const maxOffset = Math.max(trackLength - length, 0);
|
|
||||||
const maxScroll = Math.max(scrollWidth - clientWidth, 1);
|
|
||||||
const offset = (scrollLeft / maxScroll) * maxOffset;
|
|
||||||
nextHorizontal = { length, offset };
|
|
||||||
}
|
|
||||||
setHorizontal((prev) => (isSameThumbMetrics(prev, nextHorizontal) ? prev : nextHorizontal));
|
|
||||||
}, [containerRef, minThumbSize, disableHorizontal]);
|
|
||||||
|
|
||||||
const scheduleMetricsUpdate = React.useCallback(() => {
|
|
||||||
if (metricsFrameRef.current !== null) return;
|
|
||||||
metricsFrameRef.current = requestAnimationFrame(() => {
|
|
||||||
metricsFrameRef.current = null;
|
|
||||||
updateMetrics();
|
|
||||||
});
|
|
||||||
}, [updateMetrics]);
|
|
||||||
|
|
||||||
const syncObservedElements = React.useCallback((container: HTMLElement, resizeObserver: ResizeObserver | null) => {
|
|
||||||
if (!resizeObserver) {
|
|
||||||
observedElementsRef.current.clear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextObserved = new Set<Element>();
|
|
||||||
nextObserved.add(container);
|
|
||||||
Array.from(container.children).forEach((child) => {
|
|
||||||
nextObserved.add(child);
|
|
||||||
});
|
|
||||||
|
|
||||||
observedElementsRef.current.forEach((element) => {
|
|
||||||
if (!nextObserved.has(element)) {
|
|
||||||
resizeObserver.unobserve(element);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
nextObserved.forEach((element) => {
|
|
||||||
if (!observedElementsRef.current.has(element)) {
|
|
||||||
resizeObserver.observe(element);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observedElementsRef.current = nextObserved;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scheduleHide = React.useCallback(() => {
|
|
||||||
if (hideTimeoutRef.current) {
|
|
||||||
clearTimeout(hideTimeoutRef.current);
|
|
||||||
}
|
|
||||||
// Don't schedule hide if hovering over the thumb
|
|
||||||
if (isHoveringRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
hideTimeoutRef.current = setTimeout(() => setVisible(false), hideDelayMs);
|
|
||||||
}, [hideDelayMs]);
|
|
||||||
|
|
||||||
const markUserIntent = React.useCallback(() => {
|
|
||||||
lastUserIntentAtRef.current = Date.now();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleScroll = React.useCallback(() => {
|
|
||||||
if (frameRef.current) {
|
|
||||||
cancelAnimationFrame(frameRef.current);
|
|
||||||
}
|
|
||||||
frameRef.current = requestAnimationFrame(() => {
|
|
||||||
updateMetrics();
|
|
||||||
if (suppressVisibility && !isDraggingRef.current) {
|
|
||||||
setVisible(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (userIntentOnly && !isDraggingRef.current) {
|
|
||||||
const hasRecentUserIntent = Date.now() - lastUserIntentAtRef.current <= USER_SCROLL_INTENT_WINDOW_MS;
|
|
||||||
if (!hasRecentUserIntent) {
|
|
||||||
setVisible(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setVisible(true);
|
|
||||||
scheduleHide();
|
|
||||||
});
|
|
||||||
}, [scheduleHide, suppressVisibility, updateMetrics, userIntentOnly]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
updateMetrics();
|
|
||||||
setVisible(false);
|
|
||||||
|
|
||||||
const onScroll = () => handleScroll();
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (
|
|
||||||
event.key === 'ArrowUp'
|
|
||||||
|| event.key === 'ArrowDown'
|
|
||||||
|| event.key === 'PageUp'
|
|
||||||
|| event.key === 'PageDown'
|
|
||||||
|| event.key === 'Home'
|
|
||||||
|| event.key === 'End'
|
|
||||||
|| event.key === ' '
|
|
||||||
) {
|
|
||||||
markUserIntent();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
container.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
if (userIntentOnly) {
|
|
||||||
container.addEventListener("wheel", markUserIntent, { passive: true });
|
|
||||||
container.addEventListener("touchstart", markUserIntent, { passive: true });
|
|
||||||
container.addEventListener("touchmove", markUserIntent, { passive: true });
|
|
||||||
container.addEventListener("keydown", onKeyDown);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resizeObserver =
|
|
||||||
typeof ResizeObserver !== "undefined"
|
|
||||||
? new ResizeObserver(() => {
|
|
||||||
scheduleMetricsUpdate();
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
syncObservedElements(container, resizeObserver);
|
|
||||||
|
|
||||||
const mutationObserver =
|
|
||||||
observeMutations && typeof MutationObserver !== "undefined"
|
|
||||||
? new MutationObserver(() => {
|
|
||||||
syncObservedElements(container, resizeObserver);
|
|
||||||
scheduleMetricsUpdate();
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
mutationObserver?.observe(container, { childList: true });
|
|
||||||
|
|
||||||
const onInput = () => scheduleMetricsUpdate();
|
|
||||||
const onLoad = () => scheduleMetricsUpdate();
|
|
||||||
container.addEventListener("input", onInput, true);
|
|
||||||
container.addEventListener("load", onLoad, true);
|
|
||||||
|
|
||||||
|
const binding = bindScrollbar(container, root, verticalThumb, horizontalThumb, initialOptionsRef.current);
|
||||||
|
bindingRef.current = binding;
|
||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener("scroll", onScroll);
|
bindingRef.current = null;
|
||||||
container.removeEventListener("input", onInput, true);
|
binding.disconnect();
|
||||||
container.removeEventListener("load", onLoad, true);
|
|
||||||
if (userIntentOnly) {
|
|
||||||
container.removeEventListener("wheel", markUserIntent);
|
|
||||||
container.removeEventListener("touchstart", markUserIntent);
|
|
||||||
container.removeEventListener("touchmove", markUserIntent);
|
|
||||||
container.removeEventListener("keydown", onKeyDown);
|
|
||||||
}
|
|
||||||
resizeObserver?.disconnect();
|
|
||||||
mutationObserver?.disconnect();
|
|
||||||
observedElementsRef.current.clear();
|
|
||||||
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
|
|
||||||
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
|
||||||
if (metricsFrameRef.current) cancelAnimationFrame(metricsFrameRef.current);
|
|
||||||
};
|
};
|
||||||
}, [containerRef, handleScroll, markUserIntent, observeMutations, scheduleMetricsUpdate, syncObservedElements, updateMetrics, userIntentOnly]);
|
}, [containerRef]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (!suppressVisibility) {
|
bindingRef.current?.update({
|
||||||
return;
|
minThumbSize,
|
||||||
}
|
hideDelayMs,
|
||||||
if (isDraggingRef.current) {
|
disableHorizontal,
|
||||||
return;
|
observeMutations,
|
||||||
}
|
suppressVisibility,
|
||||||
setVisible(false);
|
userIntentOnly,
|
||||||
if (hideTimeoutRef.current) {
|
});
|
||||||
clearTimeout(hideTimeoutRef.current);
|
}, [disableHorizontal, hideDelayMs, minThumbSize, observeMutations, suppressVisibility, userIntentOnly]);
|
||||||
hideTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
}, [suppressVisibility]);
|
|
||||||
|
|
||||||
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>, axis: "vertical" | "horizontal") => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
isDraggingRef.current = true;
|
|
||||||
dragStartRef.current = {
|
|
||||||
pointerX: event.clientX,
|
|
||||||
pointerY: event.clientY,
|
|
||||||
scrollTop: container.scrollTop,
|
|
||||||
scrollLeft: container.scrollLeft,
|
|
||||||
};
|
|
||||||
dragAxisRef.current = axis;
|
|
||||||
markUserIntent();
|
|
||||||
setVisible(true);
|
|
||||||
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
|
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (!isDraggingRef.current) return;
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const axis = dragAxisRef.current;
|
|
||||||
if (axis === "vertical") {
|
|
||||||
const { pointerY, scrollTop } = dragStartRef.current;
|
|
||||||
const delta = event.clientY - pointerY;
|
|
||||||
const trackLength = container.clientHeight;
|
|
||||||
const thumbTravel = Math.max(trackLength - vertical.length, 1);
|
|
||||||
const maxScroll = Math.max(container.scrollHeight - container.clientHeight, 1);
|
|
||||||
const scrollDelta = (delta / thumbTravel) * maxScroll;
|
|
||||||
container.scrollTop = scrollTop + scrollDelta;
|
|
||||||
} else if (axis === "horizontal") {
|
|
||||||
const { pointerX, scrollLeft } = dragStartRef.current;
|
|
||||||
const delta = event.clientX - pointerX;
|
|
||||||
const trackLength = container.clientWidth;
|
|
||||||
const thumbTravel = Math.max(trackLength - horizontal.length, 1);
|
|
||||||
const maxScroll = Math.max(container.scrollWidth - container.clientWidth, 1);
|
|
||||||
const scrollDelta = (delta / thumbTravel) * maxScroll;
|
|
||||||
container.scrollLeft = scrollLeft + scrollDelta;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (!isDraggingRef.current) return;
|
|
||||||
isDraggingRef.current = false;
|
|
||||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
||||||
scheduleHide();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleThumbMouseEnter = React.useCallback(() => {
|
|
||||||
isHoveringRef.current = true;
|
|
||||||
// Cancel any pending hide when hovering
|
|
||||||
if (hideTimeoutRef.current) {
|
|
||||||
clearTimeout(hideTimeoutRef.current);
|
|
||||||
hideTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleThumbMouseLeave = React.useCallback(() => {
|
|
||||||
isHoveringRef.current = false;
|
|
||||||
// Schedule hide when leaving the thumb
|
|
||||||
scheduleHide();
|
|
||||||
}, [scheduleHide]);
|
|
||||||
|
|
||||||
const showVertical = vertical.length > 0;
|
|
||||||
const showHorizontal = horizontal.length > 0;
|
|
||||||
if (!showVertical && !showHorizontal) return null;
|
|
||||||
|
|
||||||
const trackInset = 8;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={rootRef} className={cn("overlay-scrollbar", className)} aria-hidden="true">
|
||||||
className={cn("overlay-scrollbar", className)}
|
<div
|
||||||
aria-hidden="true"
|
ref={verticalThumbRef}
|
||||||
style={{ opacity: visible ? 1 : 0 }}
|
hidden
|
||||||
>
|
className="overlay-scrollbar__thumb overlay-scrollbar__thumb--vertical"
|
||||||
{showVertical && (
|
data-overlay-scrollbar-thumb="vertical"
|
||||||
<div
|
style={{ right: `${TRACK_INSET / 2}px` }}
|
||||||
className="overlay-scrollbar__thumb overlay-scrollbar__thumb--vertical"
|
/>
|
||||||
data-overlay-scrollbar-thumb="vertical"
|
<div
|
||||||
style={{
|
ref={horizontalThumbRef}
|
||||||
height: `${vertical.length}px`,
|
hidden
|
||||||
top: `${trackInset + vertical.offset}px`,
|
className="overlay-scrollbar__thumb overlay-scrollbar__thumb--horizontal"
|
||||||
right: `${trackInset / 2}px`,
|
data-overlay-scrollbar-thumb="horizontal"
|
||||||
}}
|
style={{ bottom: `${TRACK_INSET / 2}px` }}
|
||||||
onPointerDown={(e) => handlePointerDown(e, "vertical")}
|
/>
|
||||||
onPointerMove={handlePointerMove}
|
|
||||||
onPointerUp={handlePointerUp}
|
|
||||||
onPointerCancel={handlePointerUp}
|
|
||||||
onMouseEnter={handleThumbMouseEnter}
|
|
||||||
onMouseLeave={handleThumbMouseLeave}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showHorizontal && (
|
|
||||||
<div
|
|
||||||
className="overlay-scrollbar__thumb overlay-scrollbar__thumb--horizontal"
|
|
||||||
data-overlay-scrollbar-thumb="horizontal"
|
|
||||||
style={{
|
|
||||||
width: `${horizontal.length}px`,
|
|
||||||
left: `${trackInset + horizontal.offset}px`,
|
|
||||||
bottom: `${trackInset / 2}px`,
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => handlePointerDown(e, "horizontal")}
|
|
||||||
onPointerMove={handlePointerMove}
|
|
||||||
onPointerUp={handlePointerUp}
|
|
||||||
onPointerCancel={handlePointerUp}
|
|
||||||
onMouseEnter={handleThumbMouseEnter}
|
|
||||||
onMouseLeave={handleThumbMouseLeave}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
OverlayScrollbarComponent.displayName = "OverlayScrollbar";
|
OverlayScrollbar.displayName = "OverlayScrollbar";
|
||||||
|
|
||||||
export const OverlayScrollbar = OverlayScrollbarComponent;
|
|
||||||
|
|||||||
@@ -652,28 +652,30 @@ html:not(.dark) .chat-scroll {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
/* Above sticky zone headers (z-20) — the thumb must never dive under
|
/* Above sticky zone headers (z-20) — the thumb must never dive under
|
||||||
a stuck sidebar header while scrolling. */
|
a stuck sidebar header while scrolling. */
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-scrollbar--dense,
|
.overlay-scrollbar[data-visible="true"] {
|
||||||
.overlay-scrollbar--bottom-right,
|
opacity: 1;
|
||||||
.overlay-scrollbar--zero,
|
|
||||||
.overlay-scrollbar--flush {
|
|
||||||
/* no-op: positioning handled inline */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-scrollbar__thumb {
|
.overlay-scrollbar__thumb {
|
||||||
pointer-events: auto;
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
background: var(--oc-scrollbar-thumb);
|
background: var(--oc-scrollbar-thumb);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
transition: background-color 0.15s ease, opacity 0.15s ease;
|
transition: background-color 0.15s ease, opacity 0.15s ease;
|
||||||
will-change: transform, height;
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-scrollbar[data-visible="true"] .overlay-scrollbar__thumb {
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-scrollbar__thumb:hover,
|
.overlay-scrollbar__thumb:hover,
|
||||||
|
|||||||
Reference in New Issue
Block a user