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>
|
||||
</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');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user