perf(ui): streamline overlay scrollbar updates

This commit is contained in:
c_w_xiaohei
2026-08-26 00:42:36 +08:00
parent cd179bd118
commit dd6a632bc5
4 changed files with 670 additions and 332 deletions
@@ -391,7 +391,13 @@ const ChatViewport = React.memo(({
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
</div>
</ScrollShadow>
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
<OverlayScrollbar
containerRef={scrollRef}
disableHorizontal
suppressVisibility={isProgrammaticFollowActive}
userIntentOnly
observeMutations={false}
/>
{showPromptNavigator && promptTurnIds.length >= 2 ? (
<PromptNavigatorRail
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');
});
});
+369 -324
View File
@@ -2,30 +2,340 @@ import React from "react";
import { cn } from "@/lib/utils";
type OverlayScrollbarProps = {
/** The authoritative scrolling element. Its identity must stay stable while mounted. */
containerRef: React.RefObject<HTMLElement | null>;
/** Minimum thumb length in CSS pixels, capped to the available track. */
minThumbSize?: number;
/** Time in milliseconds before an inactive thumb fades out. */
hideDelayMs?: number;
className?: string;
/** Skips horizontal geometry reads and keeps the horizontal thumb hidden. */
disableHorizontal?: boolean;
/** Tracks direct-child replacement so newly mounted content remains size-observed. */
observeMutations?: boolean;
/** Hides the scrollbar during programmatic motion, except while the user is dragging it. */
suppressVisibility?: boolean;
/** Shows the scrollbar only after recent wheel, touch, keyboard, or thumb input. */
userIntentOnly?: boolean;
};
type ThumbMetrics = {
length: number;
offset: number;
};
type ScrollbarOptions = Required<Pick<
OverlayScrollbarProps,
"minThumbSize" | "hideDelayMs" | "disableHorizontal" | "observeMutations" | "suppressVisibility" | "userIntentOnly"
>>;
const USER_SCROLL_INTENT_WINDOW_MS = 1000;
const METRIC_EPSILON = 0.5;
const EMPTY_THUMB: ThumbMetrics = { length: 0, offset: 0 };
// The inset is part of both rendering and drag math; changing it must preserve that shared track.
const TRACK_INSET = 8;
const USER_INTENT_DURATION_MS = 1000;
const SCROLL_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "]);
const isSameThumbMetrics = (a: ThumbMetrics, b: ThumbMetrics): boolean => {
return Math.abs(a.length - b.length) < METRIC_EPSILON && Math.abs(a.offset - b.offset) < METRIC_EPSILON;
};
function calculateThumb(viewportLength: number, contentLength: number, minThumbSize: number) {
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,
minThumbSize = 32,
hideDelayMs = 1000,
@@ -35,328 +345,63 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
suppressVisibility = false,
userIntentOnly = false,
}) => {
const [visible, setVisible] = React.useState(false);
const [vertical, setVertical] = React.useState<ThumbMetrics>({ length: 0, offset: 0 });
const [horizontal, setHorizontal] = React.useState<ThumbMetrics>({ length: 0, offset: 0 });
const hideTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const frameRef = React.useRef<number | null>(null);
const metricsFrameRef = React.useRef<number | null>(null);
const isDraggingRef = React.useRef(false);
const isHoveringRef = React.useRef(false);
const lastUserIntentAtRef = React.useRef(0);
const dragStartRef = React.useRef<{
pointerX: number;
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 rootRef = React.useRef<HTMLDivElement>(null);
const verticalThumbRef = React.useRef<HTMLDivElement>(null);
const horizontalThumbRef = React.useRef<HTMLDivElement>(null);
const bindingRef = React.useRef<ReturnType<typeof bindScrollbar> | null>(null);
const initialOptionsRef = React.useRef<ScrollbarOptions>({
minThumbSize,
hideDelayMs,
disableHorizontal,
observeMutations,
suppressVisibility,
userIntentOnly,
});
const updateMetrics = React.useCallback(() => {
React.useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const { scrollHeight, clientHeight, scrollTop, scrollWidth, clientWidth, scrollLeft } = container;
const trackInset = 8;
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 root = rootRef.current;
const verticalThumb = verticalThumbRef.current;
const horizontalThumb = horizontalThumbRef.current;
if (!container || !root || !verticalThumb || !horizontalThumb) return;
const binding = bindScrollbar(container, root, verticalThumb, horizontalThumb, initialOptionsRef.current);
bindingRef.current = binding;
return () => {
container.removeEventListener("scroll", onScroll);
container.removeEventListener("input", onInput, true);
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);
bindingRef.current = null;
binding.disconnect();
};
}, [containerRef, handleScroll, markUserIntent, observeMutations, scheduleMetricsUpdate, syncObservedElements, updateMetrics, userIntentOnly]);
}, [containerRef]);
React.useEffect(() => {
if (!suppressVisibility) {
return;
}
if (isDraggingRef.current) {
return;
}
setVisible(false);
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
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;
React.useLayoutEffect(() => {
bindingRef.current?.update({
minThumbSize,
hideDelayMs,
disableHorizontal,
observeMutations,
suppressVisibility,
userIntentOnly,
});
}, [disableHorizontal, hideDelayMs, minThumbSize, observeMutations, suppressVisibility, userIntentOnly]);
return (
<div
className={cn("overlay-scrollbar", className)}
aria-hidden="true"
style={{ opacity: visible ? 1 : 0 }}
>
{showVertical && (
<div
className="overlay-scrollbar__thumb overlay-scrollbar__thumb--vertical"
data-overlay-scrollbar-thumb="vertical"
style={{
height: `${vertical.length}px`,
top: `${trackInset + vertical.offset}px`,
right: `${trackInset / 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 ref={rootRef} className={cn("overlay-scrollbar", className)} aria-hidden="true">
<div
ref={verticalThumbRef}
hidden
className="overlay-scrollbar__thumb overlay-scrollbar__thumb--vertical"
data-overlay-scrollbar-thumb="vertical"
style={{ right: `${TRACK_INSET / 2}px` }}
/>
<div
ref={horizontalThumbRef}
hidden
className="overlay-scrollbar__thumb overlay-scrollbar__thumb--horizontal"
data-overlay-scrollbar-thumb="horizontal"
style={{ bottom: `${TRACK_INSET / 2}px` }}
/>
</div>
);
};
OverlayScrollbarComponent.displayName = "OverlayScrollbar";
export const OverlayScrollbar = OverlayScrollbarComponent;
OverlayScrollbar.displayName = "OverlayScrollbar";
+9 -7
View File
@@ -652,28 +652,30 @@ html:not(.dark) .chat-scroll {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
/* Above sticky zone headers (z-20) — the thumb must never dive under
a stuck sidebar header while scrolling. */
z-index: 30;
}
.overlay-scrollbar--dense,
.overlay-scrollbar--bottom-right,
.overlay-scrollbar--zero,
.overlay-scrollbar--flush {
/* no-op: positioning handled inline */
.overlay-scrollbar[data-visible="true"] {
opacity: 1;
}
.overlay-scrollbar__thumb {
pointer-events: auto;
pointer-events: none;
position: absolute;
width: 6px;
background: var(--oc-scrollbar-thumb);
border-radius: 9999px;
opacity: 0.8;
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,