mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-23 03:24:42 -07:00
jacob314/drag scrollbar (#12998)
This commit is contained in:
@@ -133,6 +133,16 @@ function ScrollableList<T>(
|
|||||||
Math.min(maxScrollTop, effectiveTarget),
|
Math.min(maxScrollTop, effectiveTarget),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (duration === 0) {
|
||||||
|
if (targetScrollTop === SCROLL_TO_ITEM_END) {
|
||||||
|
virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END);
|
||||||
|
} else {
|
||||||
|
virtualizedListRef.current?.scrollTo(Math.round(clampedTarget));
|
||||||
|
}
|
||||||
|
flashScrollbar();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
smoothScrollState.current = {
|
smoothScrollState.current = {
|
||||||
active: true,
|
active: true,
|
||||||
start: Date.now(),
|
start: Date.now(),
|
||||||
@@ -205,10 +215,17 @@ function ScrollableList<T>(
|
|||||||
ref: containerRef as React.RefObject<DOMElement>,
|
ref: containerRef as React.RefObject<DOMElement>,
|
||||||
getScrollState,
|
getScrollState,
|
||||||
scrollBy: scrollByWithAnimation,
|
scrollBy: scrollByWithAnimation,
|
||||||
|
scrollTo: smoothScrollTo,
|
||||||
hasFocus: hasFocusCallback,
|
hasFocus: hasFocusCallback,
|
||||||
flashScrollbar,
|
flashScrollbar,
|
||||||
}),
|
}),
|
||||||
[getScrollState, hasFocusCallback, flashScrollbar, scrollByWithAnimation],
|
[
|
||||||
|
getScrollState,
|
||||||
|
hasFocusCallback,
|
||||||
|
flashScrollbar,
|
||||||
|
scrollByWithAnimation,
|
||||||
|
smoothScrollTo,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useScrollable(scrollableEntry, hasFocus);
|
useScrollable(scrollableEntry, hasFocus);
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render } from '../../test-utils/render.js';
|
||||||
|
import {
|
||||||
|
ScrollProvider,
|
||||||
|
useScrollable,
|
||||||
|
type ScrollState,
|
||||||
|
} from './ScrollProvider.js';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { useRef, useImperativeHandle, forwardRef, type RefObject } from 'react';
|
||||||
|
import { Box, type DOMElement } from 'ink';
|
||||||
|
import type { MouseEvent } from '../hooks/useMouse.js';
|
||||||
|
|
||||||
|
// Mock useMouse hook
|
||||||
|
const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void>();
|
||||||
|
vi.mock('../hooks/useMouse.js', async () => {
|
||||||
|
// We need to import React dynamically because this factory runs before top-level imports
|
||||||
|
const React = await import('react');
|
||||||
|
return {
|
||||||
|
useMouse: (callback: (event: MouseEvent) => void) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
mockUseMouseCallbacks.add(callback);
|
||||||
|
return () => {
|
||||||
|
mockUseMouseCallbacks.delete(callback);
|
||||||
|
};
|
||||||
|
}, [callback]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock ink's getBoundingBox
|
||||||
|
vi.mock('ink', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('ink')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getBoundingBox: vi.fn(() => ({ x: 0, y: 0, width: 10, height: 10 })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const TestScrollable = forwardRef(
|
||||||
|
(
|
||||||
|
props: {
|
||||||
|
id: string;
|
||||||
|
scrollBy: (delta: number) => void;
|
||||||
|
getScrollState: () => ScrollState;
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const elementRef = useRef<DOMElement>(null);
|
||||||
|
useImperativeHandle(ref, () => elementRef.current);
|
||||||
|
|
||||||
|
useScrollable(
|
||||||
|
{
|
||||||
|
ref: elementRef as RefObject<DOMElement>,
|
||||||
|
getScrollState: props.getScrollState,
|
||||||
|
scrollBy: props.scrollBy,
|
||||||
|
hasFocus: () => true,
|
||||||
|
flashScrollbar: () => {},
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return <Box ref={elementRef} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
TestScrollable.displayName = 'TestScrollable';
|
||||||
|
|
||||||
|
describe('ScrollProvider Drag', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mockUseMouseCallbacks.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drags the scrollbar thumb', async () => {
|
||||||
|
const scrollBy = vi.fn();
|
||||||
|
const getScrollState = vi.fn(() => ({
|
||||||
|
scrollTop: 0,
|
||||||
|
scrollHeight: 100,
|
||||||
|
innerHeight: 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ScrollProvider>
|
||||||
|
<TestScrollable
|
||||||
|
id="test-scrollable"
|
||||||
|
scrollBy={scrollBy}
|
||||||
|
getScrollState={getScrollState}
|
||||||
|
/>
|
||||||
|
</ScrollProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scrollbar at x + width = 10.
|
||||||
|
// Height 10.
|
||||||
|
// scrollHeight 100, innerHeight 10.
|
||||||
|
// thumbHeight = 1.
|
||||||
|
// maxScrollTop = 90. maxThumbY = 9. Ratio = 10.
|
||||||
|
// Thumb at 0.
|
||||||
|
|
||||||
|
// 1. Click on thumb (row 0)
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'left-press',
|
||||||
|
col: 10,
|
||||||
|
row: 0,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Move mouse to row 1
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'move',
|
||||||
|
col: 10, // col doesn't matter for move if dragging
|
||||||
|
row: 1,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delta row = 1. Delta scroll = 10.
|
||||||
|
// scrollBy called with 10.
|
||||||
|
expect(scrollBy).toHaveBeenCalledWith(10);
|
||||||
|
|
||||||
|
// 3. Move mouse to row 2
|
||||||
|
scrollBy.mockClear();
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'move',
|
||||||
|
col: 10,
|
||||||
|
row: 2,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delta row from start (0) is 2. Delta scroll = 20.
|
||||||
|
// startScrollTop was 0. target 20.
|
||||||
|
// scrollBy called with (20 - scrollTop). scrollTop is still 0 in mock.
|
||||||
|
expect(scrollBy).toHaveBeenCalledWith(20);
|
||||||
|
|
||||||
|
// 4. Release
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'left-release',
|
||||||
|
col: 10,
|
||||||
|
row: 2,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Move again - should not scroll
|
||||||
|
scrollBy.mockClear();
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'move',
|
||||||
|
col: 10,
|
||||||
|
row: 3,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(scrollBy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jumps to position and starts drag when clicking track below thumb', async () => {
|
||||||
|
const scrollBy = vi.fn();
|
||||||
|
const getScrollState = vi.fn(() => ({
|
||||||
|
scrollTop: 0,
|
||||||
|
scrollHeight: 100,
|
||||||
|
innerHeight: 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ScrollProvider>
|
||||||
|
<TestScrollable
|
||||||
|
id="test-scrollable"
|
||||||
|
scrollBy={scrollBy}
|
||||||
|
getScrollState={getScrollState}
|
||||||
|
/>
|
||||||
|
</ScrollProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Thumb at 0. Click at 5.
|
||||||
|
// thumbHeight 1.
|
||||||
|
// targetThumbY = 5.
|
||||||
|
// targetScrollTop = 50.
|
||||||
|
|
||||||
|
// 1. Click on track below thumb
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'left-press',
|
||||||
|
col: 10,
|
||||||
|
row: 5,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should jump to 50 (delta 50)
|
||||||
|
expect(scrollBy).toHaveBeenCalledWith(50);
|
||||||
|
scrollBy.mockClear();
|
||||||
|
|
||||||
|
// 2. Move mouse to 6 - should drag
|
||||||
|
// Start drag captured at row 5, startScrollTop 50.
|
||||||
|
// Move to 6. Delta row 1. Delta scroll 10.
|
||||||
|
// Target = 60.
|
||||||
|
// scrollBy called with 60 - 0 (current state still 0).
|
||||||
|
// Note: In real app, state would update, but here getScrollState is static mock 0.
|
||||||
|
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'move',
|
||||||
|
col: 10,
|
||||||
|
row: 6,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(scrollBy).toHaveBeenCalledWith(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jumps to position when clicking track above thumb', async () => {
|
||||||
|
const scrollBy = vi.fn();
|
||||||
|
// Start scrolled down
|
||||||
|
const getScrollState = vi.fn(() => ({
|
||||||
|
scrollTop: 50,
|
||||||
|
scrollHeight: 100,
|
||||||
|
innerHeight: 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ScrollProvider>
|
||||||
|
<TestScrollable
|
||||||
|
id="test-scrollable"
|
||||||
|
scrollBy={scrollBy}
|
||||||
|
getScrollState={getScrollState}
|
||||||
|
/>
|
||||||
|
</ScrollProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Thumb at 5. Click at 2.
|
||||||
|
// targetThumbY = 2.
|
||||||
|
// targetScrollTop = 20.
|
||||||
|
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'left-press',
|
||||||
|
col: 10,
|
||||||
|
row: 2,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jump to 20 (delta = 20 - 50 = -30)
|
||||||
|
expect(scrollBy).toHaveBeenCalledWith(-30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jumps to top when clicking very top of track', async () => {
|
||||||
|
const scrollBy = vi.fn();
|
||||||
|
const getScrollState = vi.fn(() => ({
|
||||||
|
scrollTop: 50,
|
||||||
|
scrollHeight: 100,
|
||||||
|
innerHeight: 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ScrollProvider>
|
||||||
|
<TestScrollable
|
||||||
|
id="test-scrollable"
|
||||||
|
scrollBy={scrollBy}
|
||||||
|
getScrollState={getScrollState}
|
||||||
|
/>
|
||||||
|
</ScrollProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Thumb at 5. Click at 0.
|
||||||
|
// targetThumbY = 0.
|
||||||
|
// targetScrollTop = 0.
|
||||||
|
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'left-press',
|
||||||
|
col: 10,
|
||||||
|
row: 0,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to top (delta = 0 - 50 = -50)
|
||||||
|
expect(scrollBy).toHaveBeenCalledWith(-50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jumps to bottom when clicking very bottom of track', async () => {
|
||||||
|
const scrollBy = vi.fn();
|
||||||
|
const getScrollState = vi.fn(() => ({
|
||||||
|
scrollTop: 0,
|
||||||
|
scrollHeight: 100,
|
||||||
|
innerHeight: 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ScrollProvider>
|
||||||
|
<TestScrollable
|
||||||
|
id="test-scrollable"
|
||||||
|
scrollBy={scrollBy}
|
||||||
|
getScrollState={getScrollState}
|
||||||
|
/>
|
||||||
|
</ScrollProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Thumb at 0. Click at 9.
|
||||||
|
// targetThumbY = 9.
|
||||||
|
// targetScrollTop = 90.
|
||||||
|
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'left-press',
|
||||||
|
col: 10,
|
||||||
|
row: 9,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to bottom (delta = 90 - 0 = 90)
|
||||||
|
expect(scrollBy).toHaveBeenCalledWith(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses scrollTo with 0 duration if provided', async () => {
|
||||||
|
const scrollBy = vi.fn();
|
||||||
|
const scrollTo = vi.fn();
|
||||||
|
const getScrollState = vi.fn(() => ({
|
||||||
|
scrollTop: 0,
|
||||||
|
scrollHeight: 100,
|
||||||
|
innerHeight: 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Custom component that provides scrollTo
|
||||||
|
const TestScrollableWithScrollTo = forwardRef(
|
||||||
|
(
|
||||||
|
props: {
|
||||||
|
id: string;
|
||||||
|
scrollBy: (delta: number) => void;
|
||||||
|
scrollTo: (scrollTop: number, duration?: number) => void;
|
||||||
|
getScrollState: () => ScrollState;
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const elementRef = useRef<DOMElement>(null);
|
||||||
|
useImperativeHandle(ref, () => elementRef.current);
|
||||||
|
useScrollable(
|
||||||
|
{
|
||||||
|
ref: elementRef as RefObject<DOMElement>,
|
||||||
|
getScrollState: props.getScrollState,
|
||||||
|
scrollBy: props.scrollBy,
|
||||||
|
scrollTo: props.scrollTo,
|
||||||
|
hasFocus: () => true,
|
||||||
|
flashScrollbar: () => {},
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return <Box ref={elementRef} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
TestScrollableWithScrollTo.displayName = 'TestScrollableWithScrollTo';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ScrollProvider>
|
||||||
|
<TestScrollableWithScrollTo
|
||||||
|
id="test-scrollable-scrollto"
|
||||||
|
scrollBy={scrollBy}
|
||||||
|
scrollTo={scrollTo}
|
||||||
|
getScrollState={getScrollState}
|
||||||
|
/>
|
||||||
|
</ScrollProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click on track (jump)
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'left-press',
|
||||||
|
col: 10,
|
||||||
|
row: 5,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect scrollTo to be called with target (and undefined/default duration)
|
||||||
|
expect(scrollTo).toHaveBeenCalledWith(50);
|
||||||
|
|
||||||
|
scrollTo.mockClear();
|
||||||
|
|
||||||
|
// Move mouse (drag)
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'move',
|
||||||
|
col: 10,
|
||||||
|
row: 6,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Expect scrollTo to be called with target and duration 0
|
||||||
|
expect(scrollTo).toHaveBeenCalledWith(60, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,6 +46,7 @@ const TestScrollable = forwardRef(
|
|||||||
props: {
|
props: {
|
||||||
id: string;
|
id: string;
|
||||||
scrollBy: (delta: number) => void;
|
scrollBy: (delta: number) => void;
|
||||||
|
scrollTo?: (scrollTop: number) => void;
|
||||||
getScrollState: () => ScrollState;
|
getScrollState: () => ScrollState;
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -58,6 +59,7 @@ const TestScrollable = forwardRef(
|
|||||||
ref: elementRef as RefObject<DOMElement>,
|
ref: elementRef as RefObject<DOMElement>,
|
||||||
getScrollState: props.getScrollState,
|
getScrollState: props.getScrollState,
|
||||||
scrollBy: props.scrollBy,
|
scrollBy: props.scrollBy,
|
||||||
|
scrollTo: props.scrollTo,
|
||||||
hasFocus: () => true,
|
hasFocus: () => true,
|
||||||
flashScrollbar: () => {},
|
flashScrollbar: () => {},
|
||||||
},
|
},
|
||||||
@@ -79,6 +81,82 @@ describe('ScrollProvider', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('calls scrollTo when clicking scrollbar track if available', async () => {
|
||||||
|
const scrollBy = vi.fn();
|
||||||
|
const scrollTo = vi.fn();
|
||||||
|
const getScrollState = vi.fn(() => ({
|
||||||
|
scrollTop: 0,
|
||||||
|
scrollHeight: 100,
|
||||||
|
innerHeight: 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ScrollProvider>
|
||||||
|
<TestScrollable
|
||||||
|
id="test-scrollable"
|
||||||
|
scrollBy={scrollBy}
|
||||||
|
scrollTo={scrollTo}
|
||||||
|
getScrollState={getScrollState}
|
||||||
|
/>
|
||||||
|
</ScrollProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scrollbar is at x + width = 0 + 10 = 10.
|
||||||
|
// Height is 10. y is 0.
|
||||||
|
// Click at col 10, row 5.
|
||||||
|
// Thumb height = 10/100 * 10 = 1.
|
||||||
|
// Max thumb Y = 10 - 1 = 9.
|
||||||
|
// Current thumb Y = 0.
|
||||||
|
// Click at row 5 (relative Y = 5). This is outside the thumb (0).
|
||||||
|
// It's a track click.
|
||||||
|
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'left-press',
|
||||||
|
col: 10,
|
||||||
|
row: 5,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(scrollTo).toHaveBeenCalled();
|
||||||
|
expect(scrollBy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls scrollBy when clicking scrollbar track if scrollTo is not available', async () => {
|
||||||
|
const scrollBy = vi.fn();
|
||||||
|
const getScrollState = vi.fn(() => ({
|
||||||
|
scrollTop: 0,
|
||||||
|
scrollHeight: 100,
|
||||||
|
innerHeight: 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ScrollProvider>
|
||||||
|
<TestScrollable
|
||||||
|
id="test-scrollable"
|
||||||
|
scrollBy={scrollBy}
|
||||||
|
getScrollState={getScrollState}
|
||||||
|
/>
|
||||||
|
</ScrollProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'left-press',
|
||||||
|
col: 10,
|
||||||
|
row: 5,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(scrollBy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('batches multiple scroll events into a single update', async () => {
|
it('batches multiple scroll events into a single update', async () => {
|
||||||
const scrollBy = vi.fn();
|
const scrollBy = vi.fn();
|
||||||
const getScrollState = vi.fn(() => ({
|
const getScrollState = vi.fn(() => ({
|
||||||
@@ -234,4 +312,120 @@ describe('ScrollProvider', () => {
|
|||||||
expect(scrollBy).toHaveBeenCalledTimes(1);
|
expect(scrollBy).toHaveBeenCalledTimes(1);
|
||||||
expect(scrollBy).toHaveBeenCalledWith(1);
|
expect(scrollBy).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('calls scrollTo when dragging scrollbar thumb if available', async () => {
|
||||||
|
const scrollBy = vi.fn();
|
||||||
|
const scrollTo = vi.fn();
|
||||||
|
const getScrollState = vi.fn(() => ({
|
||||||
|
scrollTop: 0,
|
||||||
|
scrollHeight: 100,
|
||||||
|
innerHeight: 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ScrollProvider>
|
||||||
|
<TestScrollable
|
||||||
|
id="test-scrollable"
|
||||||
|
scrollBy={scrollBy}
|
||||||
|
scrollTo={scrollTo}
|
||||||
|
getScrollState={getScrollState}
|
||||||
|
/>
|
||||||
|
</ScrollProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start drag on thumb
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'left-press',
|
||||||
|
col: 10,
|
||||||
|
row: 0,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move mouse down
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'move',
|
||||||
|
col: 10,
|
||||||
|
row: 5, // Move down 5 units
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'left-release',
|
||||||
|
col: 10,
|
||||||
|
row: 5,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(scrollTo).toHaveBeenCalled();
|
||||||
|
expect(scrollBy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls scrollBy when dragging scrollbar thumb if scrollTo is not available', async () => {
|
||||||
|
const scrollBy = vi.fn();
|
||||||
|
const getScrollState = vi.fn(() => ({
|
||||||
|
scrollTop: 0,
|
||||||
|
scrollHeight: 100,
|
||||||
|
innerHeight: 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ScrollProvider>
|
||||||
|
<TestScrollable
|
||||||
|
id="test-scrollable"
|
||||||
|
scrollBy={scrollBy}
|
||||||
|
getScrollState={getScrollState}
|
||||||
|
/>
|
||||||
|
</ScrollProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start drag on thumb
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'left-press',
|
||||||
|
col: 10,
|
||||||
|
row: 0,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move mouse down
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'move',
|
||||||
|
col: 10,
|
||||||
|
row: 5,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const callback of mockUseMouseCallbacks) {
|
||||||
|
callback({
|
||||||
|
name: 'left-release',
|
||||||
|
col: 10,
|
||||||
|
row: 5,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(scrollBy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface ScrollableEntry {
|
|||||||
ref: React.RefObject<DOMElement>;
|
ref: React.RefObject<DOMElement>;
|
||||||
getScrollState: () => ScrollState;
|
getScrollState: () => ScrollState;
|
||||||
scrollBy: (delta: number) => void;
|
scrollBy: (delta: number) => void;
|
||||||
|
scrollTo?: (scrollTop: number, duration?: number) => void;
|
||||||
hasFocus: () => boolean;
|
hasFocus: () => boolean;
|
||||||
flashScrollbar: () => void;
|
flashScrollbar: () => void;
|
||||||
}
|
}
|
||||||
@@ -98,6 +99,16 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
const pendingScrollsRef = useRef(new Map<string, number>());
|
const pendingScrollsRef = useRef(new Map<string, number>());
|
||||||
const flushScheduledRef = useRef(false);
|
const flushScheduledRef = useRef(false);
|
||||||
|
|
||||||
|
const dragStateRef = useRef<{
|
||||||
|
active: boolean;
|
||||||
|
id: string | null;
|
||||||
|
offset: number;
|
||||||
|
}>({
|
||||||
|
active: false,
|
||||||
|
id: null,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const scheduleFlush = useCallback(() => {
|
const scheduleFlush = useCallback(() => {
|
||||||
if (!flushScheduledRef.current) {
|
if (!flushScheduledRef.current) {
|
||||||
flushScheduledRef.current = true;
|
flushScheduledRef.current = true;
|
||||||
@@ -146,7 +157,91 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (mouseEvent: MouseEvent) => {
|
const handleLeftPress = (mouseEvent: MouseEvent) => {
|
||||||
|
// Check for scrollbar interaction first
|
||||||
|
for (const entry of scrollablesRef.current.values()) {
|
||||||
|
if (!entry.ref.current || !entry.hasFocus()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundingBox = getBoundingBox(entry.ref.current);
|
||||||
|
if (!boundingBox) continue;
|
||||||
|
|
||||||
|
const { x, y, width, height } = boundingBox;
|
||||||
|
|
||||||
|
// Check if click is on the scrollbar column (x + width)
|
||||||
|
// The findScrollableCandidates logic implies scrollbar is at x + width.
|
||||||
|
if (
|
||||||
|
mouseEvent.col === x + width &&
|
||||||
|
mouseEvent.row >= y &&
|
||||||
|
mouseEvent.row < y + height
|
||||||
|
) {
|
||||||
|
const { scrollTop, scrollHeight, innerHeight } = entry.getScrollState();
|
||||||
|
|
||||||
|
if (scrollHeight <= innerHeight) continue;
|
||||||
|
|
||||||
|
const thumbHeight = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((innerHeight / scrollHeight) * innerHeight),
|
||||||
|
);
|
||||||
|
const maxScrollTop = scrollHeight - innerHeight;
|
||||||
|
const maxThumbY = innerHeight - thumbHeight;
|
||||||
|
|
||||||
|
if (maxThumbY <= 0) continue;
|
||||||
|
|
||||||
|
const currentThumbY = Math.round(
|
||||||
|
(scrollTop / maxScrollTop) * maxThumbY,
|
||||||
|
);
|
||||||
|
|
||||||
|
const absoluteThumbTop = y + currentThumbY;
|
||||||
|
const absoluteThumbBottom = absoluteThumbTop + thumbHeight;
|
||||||
|
|
||||||
|
const isTop = mouseEvent.row === y;
|
||||||
|
const isBottom = mouseEvent.row === y + height - 1;
|
||||||
|
|
||||||
|
const hitTop = isTop ? absoluteThumbTop : absoluteThumbTop - 1;
|
||||||
|
const hitBottom = isBottom
|
||||||
|
? absoluteThumbBottom
|
||||||
|
: absoluteThumbBottom + 1;
|
||||||
|
|
||||||
|
const isThumbClick =
|
||||||
|
mouseEvent.row >= hitTop && mouseEvent.row < hitBottom;
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
const relativeMouseY = mouseEvent.row - y;
|
||||||
|
|
||||||
|
if (isThumbClick) {
|
||||||
|
offset = relativeMouseY - currentThumbY;
|
||||||
|
} else {
|
||||||
|
// Track click - Jump to position
|
||||||
|
// Center the thumb on the mouse click
|
||||||
|
const targetThumbY = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(maxThumbY, relativeMouseY - Math.floor(thumbHeight / 2)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const newScrollTop = Math.round(
|
||||||
|
(targetThumbY / maxThumbY) * maxScrollTop,
|
||||||
|
);
|
||||||
|
if (entry.scrollTo) {
|
||||||
|
entry.scrollTo(newScrollTop);
|
||||||
|
} else {
|
||||||
|
entry.scrollBy(newScrollTop - scrollTop);
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = relativeMouseY - targetThumbY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start drag (for both thumb and track clicks)
|
||||||
|
dragStateRef.current = {
|
||||||
|
active: true,
|
||||||
|
id: entry.id,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const candidates = findScrollableCandidates(
|
const candidates = findScrollableCandidates(
|
||||||
mouseEvent,
|
mouseEvent,
|
||||||
scrollablesRef.current,
|
scrollablesRef.current,
|
||||||
@@ -158,6 +253,60 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMove = (mouseEvent: MouseEvent) => {
|
||||||
|
const state = dragStateRef.current;
|
||||||
|
if (!state.active || !state.id) return;
|
||||||
|
|
||||||
|
const entry = scrollablesRef.current.get(state.id);
|
||||||
|
if (!entry || !entry.ref.current) {
|
||||||
|
state.active = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundingBox = getBoundingBox(entry.ref.current);
|
||||||
|
if (!boundingBox) return;
|
||||||
|
|
||||||
|
const { y } = boundingBox;
|
||||||
|
const { scrollTop, scrollHeight, innerHeight } = entry.getScrollState();
|
||||||
|
|
||||||
|
const thumbHeight = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((innerHeight / scrollHeight) * innerHeight),
|
||||||
|
);
|
||||||
|
const maxScrollTop = scrollHeight - innerHeight;
|
||||||
|
const maxThumbY = innerHeight - thumbHeight;
|
||||||
|
|
||||||
|
if (maxThumbY <= 0) return;
|
||||||
|
|
||||||
|
const relativeMouseY = mouseEvent.row - y;
|
||||||
|
// Calculate the target thumb position based on the mouse position and the offset.
|
||||||
|
// We clamp it to the valid range [0, maxThumbY].
|
||||||
|
const targetThumbY = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(maxThumbY, relativeMouseY - state.offset),
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetScrollTop = Math.round(
|
||||||
|
(targetThumbY / maxThumbY) * maxScrollTop,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (entry.scrollTo) {
|
||||||
|
entry.scrollTo(targetScrollTop, 0);
|
||||||
|
} else {
|
||||||
|
entry.scrollBy(targetScrollTop - scrollTop);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLeftRelease = () => {
|
||||||
|
if (dragStateRef.current.active) {
|
||||||
|
dragStateRef.current = {
|
||||||
|
active: false,
|
||||||
|
id: null,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useMouse(
|
useMouse(
|
||||||
(event: MouseEvent) => {
|
(event: MouseEvent) => {
|
||||||
if (event.name === 'scroll-up') {
|
if (event.name === 'scroll-up') {
|
||||||
@@ -165,7 +314,11 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
} else if (event.name === 'scroll-down') {
|
} else if (event.name === 'scroll-down') {
|
||||||
handleScroll('down', event);
|
handleScroll('down', event);
|
||||||
} else if (event.name === 'left-press') {
|
} else if (event.name === 'left-press') {
|
||||||
handleClick(event);
|
handleLeftPress(event);
|
||||||
|
} else if (event.name === 'move') {
|
||||||
|
handleMove(event);
|
||||||
|
} else if (event.name === 'left-release') {
|
||||||
|
handleLeftRelease();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ isActive: true },
|
{ isActive: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user