Skip to content

Commit aefe483

Browse files
committed
Fix scrollbar click-drag on large diffs
- Fix stale closure bug: use refs instead of state for drag tracking - Add track click support: click above/below thumb to page up/down - Add MIN_THUMB_HEIGHT=2 for easier grabbing - Remove zIndex that was blocking mouse wheel events Fixes drag interaction on large diffs (1000+ lines) where state updates were creating stale closures in the event handlers.
1 parent 9a8d16e commit aefe483

File tree

1 file changed

+46
-16
lines changed

1 file changed

+46
-16
lines changed

src/ui/components/scrollbar/VerticalScrollbar.tsx

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { AppTheme } from "../../themes";
1212

1313
const HIDE_DELAY_MS = 2000;
1414
const SCROLLBAR_WIDTH = 1;
15+
const MIN_THUMB_HEIGHT = 2;
1516

1617
export interface VerticalScrollbarHandle {
1718
show: () => void;
@@ -32,9 +33,10 @@ interface VerticalScrollbarProps {
3233
export const VerticalScrollbar = forwardRef<VerticalScrollbarHandle, VerticalScrollbarProps>(
3334
function VerticalScrollbar({ scrollRef, contentHeight, theme, height, onActivity }, ref) {
3435
const [isVisible, setIsVisible] = useState(false);
35-
const [isDragging, setIsDragging] = useState(false);
36-
const [dragStartY, setDragStartY] = useState(0);
37-
const [dragStartScroll, setDragStartScroll] = useState(0);
36+
const [isDraggingState, setIsDraggingState] = useState(false);
37+
const isDraggingRef = useRef(false);
38+
const dragStartYRef = useRef(0);
39+
const dragStartScrollRef = useRef(0);
3840
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
3941

4042
const show = useCallback(() => {
@@ -43,12 +45,12 @@ export const VerticalScrollbar = forwardRef<VerticalScrollbarHandle, VerticalScr
4345
clearTimeout(hideTimeoutRef.current);
4446
}
4547
hideTimeoutRef.current = setTimeout(() => {
46-
if (!isDragging) {
48+
if (!isDraggingRef.current) {
4749
setIsVisible(false);
4850
}
4951
}, HIDE_DELAY_MS);
5052
onActivity?.();
51-
}, [isDragging, onActivity]);
53+
}, [onActivity]);
5254

5355
useImperativeHandle(ref, () => ({ show }), [show]);
5456

@@ -67,7 +69,7 @@ export const VerticalScrollbar = forwardRef<VerticalScrollbarHandle, VerticalScr
6769
// Calculate thumb metrics
6870
const trackHeight = viewportHeight;
6971
const scrollRatio = viewportHeight / contentHeight;
70-
const thumbHeight = Math.max(SCROLLBAR_WIDTH, Math.floor(trackHeight * scrollRatio));
72+
const thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.floor(trackHeight * scrollRatio));
7173
const maxThumbY = trackHeight - thumbHeight;
7274

7375
const scrollTop = scrollRef.current?.scrollTop ?? 0;
@@ -79,31 +81,59 @@ export const VerticalScrollbar = forwardRef<VerticalScrollbarHandle, VerticalScr
7981
if (event.button !== 0) return;
8082

8183
const currentScrollTop = scrollRef.current?.scrollTop ?? 0;
82-
setIsDragging(true);
83-
setDragStartY(event.y);
84-
setDragStartScroll(currentScrollTop);
84+
isDraggingRef.current = true;
85+
setIsDraggingState(true);
86+
dragStartYRef.current = event.y;
87+
dragStartScrollRef.current = currentScrollTop;
8588
show();
8689
event.preventDefault();
8790
event.stopPropagation();
8891
};
8992

9093
const handleMouseDrag = (event: TuiMouseEvent) => {
91-
if (!isDragging) return;
94+
if (!isDraggingRef.current) {
95+
return;
96+
}
9297

93-
const deltaY = event.y - dragStartY;
98+
const deltaY = event.y - dragStartYRef.current;
9499
const pixelsPerRow = maxThumbY / maxScroll;
95100
const scrollDelta = deltaY / pixelsPerRow;
96-
const newScrollTop = Math.max(0, Math.min(maxScroll, dragStartScroll + scrollDelta));
101+
const newScrollTop = Math.max(
102+
0,
103+
Math.min(maxScroll, dragStartScrollRef.current + scrollDelta),
104+
);
97105

98106
scrollRef.current?.scrollTo(newScrollTop);
99107
show();
100108
event.preventDefault();
101109
event.stopPropagation();
102110
};
103111

112+
const handleTrackClick = (event: TuiMouseEvent) => {
113+
if (event.button !== 0) return;
114+
115+
// Calculate where on the track was clicked
116+
const clickY = event.y;
117+
118+
// If clicked above thumb, scroll up one viewport
119+
// If clicked below thumb, scroll down one viewport
120+
if (clickY < thumbY) {
121+
const newScrollTop = Math.max(0, scrollTop - viewportHeight);
122+
scrollRef.current?.scrollTo(newScrollTop);
123+
} else if (clickY >= thumbY + thumbHeight) {
124+
const newScrollTop = Math.min(maxScroll, scrollTop + viewportHeight);
125+
scrollRef.current?.scrollTo(newScrollTop);
126+
}
127+
128+
show();
129+
event.preventDefault();
130+
event.stopPropagation();
131+
};
132+
104133
const handleMouseUp = (event?: TuiMouseEvent) => {
105-
if (!isDragging) return;
106-
setIsDragging(false);
134+
if (!isDraggingRef.current) return;
135+
isDraggingRef.current = false;
136+
setIsDraggingState(false);
107137
// Restart hide timer
108138
if (hideTimeoutRef.current) {
109139
clearTimeout(hideTimeoutRef.current);
@@ -128,7 +158,6 @@ export const VerticalScrollbar = forwardRef<VerticalScrollbarHandle, VerticalScr
128158
width: SCROLLBAR_WIDTH,
129159
height: trackHeight,
130160
backgroundColor: theme.panel,
131-
zIndex: 10,
132161
}}
133162
>
134163
{/* Track background */}
@@ -141,6 +170,7 @@ export const VerticalScrollbar = forwardRef<VerticalScrollbarHandle, VerticalScr
141170
height: trackHeight,
142171
backgroundColor: theme.border,
143172
}}
173+
onMouseDown={handleTrackClick}
144174
/>
145175
{/* Thumb */}
146176
<box
@@ -150,7 +180,7 @@ export const VerticalScrollbar = forwardRef<VerticalScrollbarHandle, VerticalScr
150180
left: 0,
151181
width: SCROLLBAR_WIDTH,
152182
height: thumbHeight,
153-
backgroundColor: isDragging ? theme.accent : theme.accentMuted,
183+
backgroundColor: isDraggingState ? theme.accent : theme.accentMuted,
154184
}}
155185
onMouseDown={handleMouseDown}
156186
onMouseDrag={handleMouseDrag}

0 commit comments

Comments
 (0)